├── .envrc ├── .gitignore ├── example ├── server │ ├── .gitignore │ ├── test │ │ └── lustre_omnistate_test.gleam │ ├── src │ │ ├── server.gleam │ │ └── server │ │ │ ├── components │ │ │ ├── sessions_count.gleam │ │ │ └── chat.gleam │ │ │ ├── context.gleam │ │ │ └── router.gleam │ ├── README.md │ ├── gleam.toml │ ├── manifest.toml │ └── priv │ │ └── static │ │ └── client.css ├── shared │ ├── .gitignore │ ├── test │ │ └── shared_test.gleam │ ├── .github │ │ └── workflows │ │ │ └── test.yml │ ├── README.md │ ├── gleam.toml │ ├── manifest.toml │ └── src │ │ └── shared.gleam └── client │ ├── .gitignore │ ├── test │ └── client_test.gleam │ ├── tailwind.config.js │ ├── .github │ └── workflows │ │ └── test.yml │ ├── README.md │ ├── index.html │ ├── gleam.toml │ ├── src │ ├── client.gleam │ └── client │ │ └── chat.gleam │ └── manifest.toml ├── omnimessage_lustre ├── .gitignore ├── src │ ├── omnimessage_lustre.ffi.mjs │ ├── websocket.ffi.mjs │ └── omnimessage │ │ ├── lustre │ │ ├── internal │ │ │ └── transports │ │ │ │ └── websocket.gleam │ │ └── transports.gleam │ │ └── lustre.gleam ├── test │ └── omniclient_test.gleam ├── gleam.toml ├── manifest.toml └── README.md ├── omnimessage_server ├── .gitignore ├── test │ └── omniserver_test.gleam ├── gleam.toml ├── README.md ├── manifest.toml └── src │ └── omnimessage │ └── server.gleam ├── CHANGELOG.md ├── package.json ├── flake.lock ├── LICENSE ├── flake.nix └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | 6 | .direnv 7 | -------------------------------------------------------------------------------- /example/server/.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /example/shared/.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /omnimessage_lustre/.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /omnimessage_server/.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /example/client/.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | priv/** 6 | -------------------------------------------------------------------------------- /example/client/test/client_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | -------------------------------------------------------------------------------- /example/shared/test/shared_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | -------------------------------------------------------------------------------- /example/server/test/lustre_omnistate_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | -------------------------------------------------------------------------------- /omnimessage_lustre/src/omnimessage_lustre.ffi.mjs: -------------------------------------------------------------------------------- 1 | import { Ok, Error } from './gleam.mjs'; 2 | 3 | export function on_online_change(cb) { 4 | addEventListener("online", (_) => cb(true)); 5 | addEventListener("offline", (_) => cb(false)); 6 | return navigator.onLine; 7 | } 8 | -------------------------------------------------------------------------------- /omnimessage_lustre/test/omniclient_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | -------------------------------------------------------------------------------- /omnimessage_server/test/omniserver_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | -------------------------------------------------------------------------------- /example/client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const defaultTheme = require('tailwindcss/defaultTheme'); 4 | 5 | module.exports = { 6 | content: ["./index.html", "./src/**/*.{gleam,mjs}"], 7 | theme: {}, 8 | plugins: [ 9 | require('@tailwindcss/typography'), 10 | ], 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.2.0 4 | 5 | - BREAKING: Use `omnimessage` namespace: 6 | ```gleam 7 | // Old imports: 8 | import omnimessage_lustre 9 | import omnimessage_server 10 | 11 | // New imports 12 | import omnimessage/lustre 13 | import omnimessage/server 14 | ``` 15 | 16 | - BREAKING: Updated dependencies to latest 17 | 18 | ## v0.1.0 19 | 20 | - Initial release 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "omnistate_example_client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@tailwindcss/typography": "^0.5.15", 14 | "tailwindcss": "^3.4.13" 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /example/server/src/server.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process 2 | 3 | import mist 4 | import wisp 5 | 6 | import server/context 7 | import server/router 8 | 9 | pub fn main() { 10 | wisp.configure_logger() 11 | let secret_key_base = wisp.random_string(64) 12 | 13 | let assert Ok(context) = context.new() 14 | 15 | let handler = fn(req) { router.mist_handler(req, context, secret_key_base) } 16 | 17 | let assert Ok(_) = 18 | handler 19 | |> mist.new 20 | |> mist.port(8000) 21 | |> mist.start_http 22 | 23 | process.sleep_forever() 24 | } 25 | -------------------------------------------------------------------------------- /example/client/.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0.2" 18 | gleam-version: "1.5.1" 19 | rebar3-version: "3" 20 | # elixir-version: "1.15.4" 21 | - run: gleam deps download 22 | - run: gleam test 23 | - run: gleam format --check src test 24 | -------------------------------------------------------------------------------- /example/client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/client)](https://hex.pm/packages/client) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/client/) 5 | 6 | ```sh 7 | gleam add client@1 8 | ``` 9 | ```gleam 10 | import client 11 | 12 | pub fn main() { 13 | // TODO: An example of the project in use 14 | } 15 | ``` 16 | 17 | Further documentation can be found at . 18 | 19 | ## Development 20 | 21 | ```sh 22 | gleam run # Run the project 23 | gleam test # Run the tests 24 | ``` 25 | -------------------------------------------------------------------------------- /example/server/README.md: -------------------------------------------------------------------------------- 1 | # server 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/server)](https://hex.pm/packages/server) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/server/) 5 | 6 | ```sh 7 | gleam add server@1 8 | ``` 9 | ```gleam 10 | import server 11 | 12 | pub fn main() { 13 | // TODO: An example of the project in use 14 | } 15 | ``` 16 | 17 | Further documentation can be found at . 18 | 19 | ## Development 20 | 21 | ```sh 22 | gleam run # Run the project 23 | gleam test # Run the tests 24 | ``` 25 | -------------------------------------------------------------------------------- /example/shared/.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0.2" 18 | gleam-version: "1.5.1" 19 | rebar3-version: "3" 20 | # elixir-version: "1.15.4" 21 | - run: gleam deps download 22 | - run: gleam test 23 | - run: gleam format --check src test 24 | -------------------------------------------------------------------------------- /example/shared/README.md: -------------------------------------------------------------------------------- 1 | # shared 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/shared)](https://hex.pm/packages/shared) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/shared/) 5 | 6 | ```sh 7 | gleam add shared@1 8 | ``` 9 | ```gleam 10 | import shared 11 | 12 | pub fn main() { 13 | // TODO: An example of the project in use 14 | } 15 | ``` 16 | 17 | Further documentation can be found at . 18 | 19 | ## Development 20 | 21 | ```sh 22 | gleam run # Run the project 23 | gleam test # Run the tests 24 | ``` 25 | -------------------------------------------------------------------------------- /example/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | OmniMessage 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /omnimessage_lustre/gleam.toml: -------------------------------------------------------------------------------- 1 | name = "omnimessage_lustre" 2 | version = "0.3.0" 3 | target = "javascript" 4 | 5 | description = "Use Remote State, Locally" 6 | repository = { type = "github", user = "weedonandscott", repo = "omnimessage" } 7 | licences = ["MIT"] 8 | 9 | internal_modules = ["omnimessage/lustre/internal", "omnimessage/lustre/internal/*"] 10 | 11 | [dependencies] 12 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 13 | lustre = ">= 5.0.0 and < 6.0.0" 14 | gleam_fetch = ">= 1.0.1 and < 2.0.0" 15 | gleam_http = ">= 3.0.0 and < 5.0.0" 16 | gleam_javascript = ">= 1.0.0 and < 2.0.0" 17 | 18 | [dev-dependencies] 19 | gleeunit = ">= 1.0.0 and < 2.0.0" 20 | -------------------------------------------------------------------------------- /omnimessage_server/gleam.toml: -------------------------------------------------------------------------------- 1 | name = "omnimessage_server" 2 | version = "0.3.0" 3 | 4 | description = "Use Remote State, Locally" 5 | repository = { type = "github", user = "weedonandscott", repo = "omnimessage" } 6 | licences = ["MIT"] 7 | 8 | internal_modules = ["omnimessage/server/internal", "omnimessage/server/internal/*"] 9 | 10 | [dependencies] 11 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 12 | gleam_otp = ">= 0.14.1 and < 1.0.0" 13 | gleam_http = ">= 4.0.0 and < 5.0.0" 14 | lustre = ">= 5.0.0 and < 6.0.0" 15 | mist = ">= 4.0.0 and < 5.0.0" 16 | wisp = ">= 1.2.0 and < 2.0.0" 17 | gleam_erlang = ">= 0.30.0 and < 1.0.0" 18 | 19 | [dev-dependencies] 20 | gleeunit = ">= 1.0.0 and < 2.0.0" 21 | -------------------------------------------------------------------------------- /example/client/gleam.toml: -------------------------------------------------------------------------------- 1 | name = "client" 2 | version = "0.1.0" 3 | target = "javascript" 4 | 5 | gleam = ">= 1.5.1" 6 | 7 | [dependencies] 8 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 9 | lustre = ">= 5.0.0 and < 6.0.0" 10 | modem = ">= 2.0.1 and < 3.0.0" 11 | gleam_json = ">= 2.0.0 and < 3.0.0" 12 | plinth = ">= 0.5.9 and < 1.0.0" 13 | birl = ">= 1.7.1 and < 2.0.0" 14 | lustre_pipes = ">= 0.3.0 and < 1.0.0" 15 | omnimessage_lustre = { path = "../../omnimessage_lustre" } 16 | shared = { path = "../shared" } 17 | decode = ">= 1.1.0 and < 2.0.0" 18 | lustre_websocket = ">= 0.7.6 and < 1.0.0" 19 | 20 | [dev-dependencies] 21 | gleeunit = ">= 1.0.0 and < 2.0.0" 22 | lustre_dev_tools = ">= 1.6.0 and < 2.0.0" 23 | 24 | -------------------------------------------------------------------------------- /example/shared/gleam.toml: -------------------------------------------------------------------------------- 1 | name = "shared" 2 | version = "0.1.0" 3 | 4 | # Fill out these fields if you intend to generate HTML documentation or publish 5 | # your project to the Hex package manager. 6 | # 7 | # description = "" 8 | # licences = ["Apache-2.0"] 9 | # repository = { type = "github", user = "", repo = "" } 10 | # links = [{ title = "Website", href = "" }] 11 | # 12 | # For a full reference of all the available options, you can have a look at 13 | # https://gleam.run/writing-gleam/gleam-toml/. 14 | 15 | [dependencies] 16 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 17 | gleam_json = ">= 2.0.0 and < 3.0.0" 18 | decode = ">= 1.1.0 and < 2.0.0" 19 | birl = ">= 1.7.1 and < 2.0.0" 20 | gluid = ">= 1.0.0 and < 2.0.0" 21 | 22 | [dev-dependencies] 23 | gleeunit = ">= 1.0.0 and < 2.0.0" 24 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1732521221, 6 | "narHash": "sha256-2ThgXBUXAE1oFsVATK1ZX9IjPcS4nKFOAjhPNKuiMn0=", 7 | "rev": "4633a7c72337ea8fd23a4f2ba3972865e3ec685d", 8 | "revCount": 712559, 9 | "type": "tarball", 10 | "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.712559%2Brev-4633a7c72337ea8fd23a4f2ba3972865e3ec685d/01936a8d-e4f6-74b8-8a5d-7d6cb7c7b0b2/source.tar.gz" 11 | }, 12 | "original": { 13 | "type": "tarball", 14 | "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1.%2A.tar.gz" 15 | } 16 | }, 17 | "root": { 18 | "inputs": { 19 | "nixpkgs": "nixpkgs" 20 | } 21 | } 22 | }, 23 | "root": "root", 24 | "version": 7 25 | } 26 | -------------------------------------------------------------------------------- /example/client/src/client.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/option 3 | import gleam/result 4 | import gleam/string 5 | 6 | import lustre 7 | import plinth/browser/document 8 | import plinth/browser/element as e 9 | 10 | import client/chat 11 | 12 | // MAIN ------------------------------------------------------------------------ 13 | 14 | pub fn main() { 15 | let init_model = 16 | document.query_selector("#model") 17 | |> result.map(e.inner_text) 18 | |> result.then(fn(text) { 19 | case 20 | text 21 | |> string.trim 22 | |> string.is_empty 23 | { 24 | True -> Error(Nil) 25 | False -> Ok(text) 26 | } 27 | }) 28 | |> result.then(fn(_string_model) { 29 | // TODO: Hydrate 30 | Ok(chat.Model(dict.new(), draft_content: "")) 31 | }) 32 | |> option.from_result 33 | 34 | let assert Ok(_) = lustre.start(chat.chat(), "#app", init_model) 35 | 36 | Nil 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Weedon & Scott 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in the 5 | Software without restriction, including without limitation the rights to use, 6 | copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the 7 | Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /example/server/src/server/components/sessions_count.gleam: -------------------------------------------------------------------------------- 1 | import gleam/int 2 | import gleam/option.{type Option, None, Some} 3 | import lustre 4 | import lustre/effect 5 | import lustre_pipes/element 6 | import lustre_pipes/element/html 7 | 8 | pub type Model { 9 | Model(user_count: Option(Int)) 10 | } 11 | 12 | pub fn app() { 13 | // An omnimessage_server app has no view 14 | lustre.application(init, update, view) 15 | } 16 | 17 | fn init(count_listener: fn(fn(Int) -> Nil) -> Nil) { 18 | #( 19 | Model(None), 20 | effect.from(fn(dispatch) { 21 | count_listener(fn(new_count) { dispatch(GotNewCount(new_count)) }) 22 | }), 23 | ) 24 | } 25 | 26 | pub type Msg { 27 | GotNewCount(Int) 28 | } 29 | 30 | fn update(_model: Model, msg: Msg) { 31 | case msg { 32 | GotNewCount(new_count) -> #(Model(Some(new_count)), effect.none()) 33 | } 34 | } 35 | 36 | fn view(model: Model) { 37 | let count_message = 38 | model.user_count 39 | |> option.map(int.to_string) 40 | |> option.unwrap("Getting user count...") 41 | 42 | html.p() 43 | |> element.text_content("Online users: " <> count_message) 44 | } 45 | -------------------------------------------------------------------------------- /example/server/gleam.toml: -------------------------------------------------------------------------------- 1 | name = "server" 2 | version = "0.1.0" 3 | gleam = ">= 1.5.1" 4 | 5 | # Fill out these fields if you intend to generate HTML documentation or publish 6 | # your project to the Hex package manager. 7 | # 8 | # description = "" 9 | # licences = ["Apache-2.0"] 10 | # repository = { type = "github", user = "", repo = "" } 11 | # links = [{ title = "Website", href = "" }] 12 | # 13 | # For a full reference of all the available options, you can have a look at 14 | # https://gleam.run/writing-gleam/gleam-toml/. 15 | 16 | [dependencies] 17 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 18 | lustre = ">= 5.0.0 and < 6.0.0" 19 | wisp = ">= 1.2.0 and < 2.0.0" 20 | mist = ">= 4.0.0 and < 5.0.0" 21 | gleam_erlang = ">= 0.28.0 and < 1.0.0" 22 | gleam_http = ">= 3.0.0 and < 5.0.0" 23 | filepath = ">= 1.0.0 and < 2.0.0" 24 | lustre_pipes = ">= 0.3.0 and < 1.0.0" 25 | omnimessage_server = { path = "../../omnimessage_server" } 26 | shared = { path = "../shared" } 27 | gleam_otp = ">= 0.13.0 and < 1.0.0" 28 | gleam_json = ">= 2.0.0 and < 3.0.0" 29 | decode = ">= 1.1.0 and < 2.0.0" 30 | carpenter = ">= 0.3.1 and < 1.0.0" 31 | 32 | [dev-dependencies] 33 | gleeunit = ">= 1.0.0 and < 2.0.0" 34 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A Nix-flake-based Node.js development environment"; 3 | 4 | inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.*.tar.gz"; 5 | 6 | outputs = { self, nixpkgs }: 7 | let 8 | supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 9 | forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f { 10 | pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; }; 11 | }); 12 | in 13 | { 14 | overlays.default = final: prev: rec { 15 | nodejs = prev.nodejs; 16 | yarn = (prev.yarn.override { inherit nodejs; }); 17 | }; 18 | 19 | devShells = forEachSupportedSystem ({ pkgs }: { 20 | default = pkgs.mkShell { 21 | packages = with pkgs; [ 22 | # Gleam 23 | gleam 24 | 25 | # Erlang 26 | erlang_27 27 | rebar3 28 | 29 | # Node 30 | node2nix 31 | nodejs 32 | nodePackages.pnpm 33 | 34 | # Lustre devtools 35 | inotify-tools 36 | ]; 37 | }; 38 | }); 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /omnimessage_lustre/src/websocket.ffi.mjs: -------------------------------------------------------------------------------- 1 | import { Ok, Error } from './gleam.mjs'; 2 | import { 3 | InvalidUrl, 4 | UnsupportedEnvironment 5 | } from './omnimessage/lustre/internal/transports/websocket.mjs'; 6 | 7 | export const ws_init = (url, on_open, on_text, on_close) => { 8 | if (typeof WebSocket === "function") { 9 | try { 10 | const ws = new WebSocket(url); 11 | return new Ok(ws); 12 | } catch (error) { 13 | return Error(new InvalidUrl(error.message)); 14 | } 15 | } else { 16 | return Error(new UnsupportedEnvironment("WebSocket global unavailable")); 17 | } 18 | } 19 | 20 | export const ws_listen = (ws, on_open, on_text, on_close) => { 21 | ws.addEventListener("open", (_) => on_open(ws)); 22 | 23 | ws.addEventListener("message", (event) => { 24 | // this transport supports text only 25 | if (typeof event.data === "string") { 26 | on_text(event.data); 27 | } 28 | }); 29 | 30 | ws.addEventListener("close", (event) => 31 | on_close(event.code, event.reason ?? "") 32 | ); 33 | } 34 | 35 | export const ws_send = (ws, msg) => { 36 | ws.send(msg); 37 | } 38 | 39 | export const ws_close = (ws) => { 40 | ws.close(); 41 | } 42 | 43 | export const get_page_url = () => document.URL; 44 | 45 | -------------------------------------------------------------------------------- /example/shared/manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "birl", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "2AC7BA26F998E3DFADDB657148BD5DDFE966958AD4D6D6957DD0D22E5B56C400" }, 6 | { name = "decode", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "decode", source = "hex", outer_checksum = "9FFAD3F60600C6777072C3836B9FD965961D7C76C5D6007918AE0F82C1B21BE3" }, 7 | { name = "gleam_json", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C55C5C2B318533A8072D221C5E06E5A75711C129E420DD1CE463342106012E5D" }, 8 | { name = "gleam_regexp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "A3655FDD288571E90EE9C4009B719FEF59FA16AFCDF3952A76A125AF23CF1592" }, 9 | { name = "gleam_stdlib", version = "0.52.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "50703862DF26453B277688FFCDBE9DD4AC45B3BD9742C0B370DB62BC1629A07D" }, 10 | { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 11 | { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, 12 | { name = "gluid", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gluid", source = "hex", outer_checksum = "9C0D64DC9A345B7DCC76A2EFDC1F3479E3C6A08A1FB81156E9CCED959F8DEB78" }, 13 | { name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" }, 14 | ] 15 | 16 | [requirements] 17 | birl = { version = ">= 1.7.1 and < 2.0.0" } 18 | decode = { version = ">= 1.1.0 and < 2.0.0" } 19 | gleam_json = { version = ">= 2.0.0 and < 3.0.0" } 20 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 21 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 22 | gluid = { version = ">= 1.0.0 and < 2.0.0" } 23 | -------------------------------------------------------------------------------- /omnimessage_lustre/manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "gleam_erlang", version = "0.34.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" }, 6 | { name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" }, 7 | { name = "gleam_http", version = "4.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "0A62451FC85B98062E0907659D92E6A89F5F3C0FBE4AB8046C99936BF6F91DBC" }, 8 | { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, 9 | { name = "gleam_json", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C55C5C2B318533A8072D221C5E06E5A75711C129E420DD1CE463342106012E5D" }, 10 | { name = "gleam_otp", version = "0.16.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" }, 11 | { name = "gleam_stdlib", version = "0.59.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F8FEE9B35797301994B81AF75508CF87C328FE1585558B0FFD188DC2B32EAA95" }, 12 | { name = "gleeunit", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, 13 | { name = "houdini", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, 14 | { name = "lustre", version = "5.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "0BB69D69A9E75E675AA2C32A4A0E5086041D037829FC8AD385BA6A59E45A60A2" }, 15 | ] 16 | 17 | [requirements] 18 | gleam_fetch = { version = ">= 1.0.1 and < 2.0.0" } 19 | gleam_http = { version = ">= 3.0.0 and < 5.0.0" } 20 | gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" } 21 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 22 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 23 | lustre = { version = ">= 5.0.0 and < 6.0.0" } 24 | -------------------------------------------------------------------------------- /omnimessage_server/README.md: -------------------------------------------------------------------------------- 1 | # omnimessage_server 2 | 3 | **Seamless communication with Lustre applications** 4 | 5 | [![Package Version](https://img.shields.io/hexpm/v/omnimessage_server)](https://hex.pm/packages/omnimessage_server) 6 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/omnimessage_server/) 7 | 8 | A main challenge of maintaining a client side application is state 9 | synchronization. Usually, a complete separation is ideal, where client state is 10 | managed exclusively at the client, and server state exclusively at the server. 11 | There's nuance to this take, but it's a good general rule to follow. 12 | 13 | [Lustre](https://hexdocs.pm/lustre/) exceles at this with having both the power 14 | of a full-blown single page application, and the flexibility of using server 15 | components for a LiveView/HTMX like solution to state fully owned by the server. 16 | 17 | This approach becomes brittle when the state is shared between both the client 18 | and server. In a chat app, for example, the messages are state owned by the 19 | server, but you still need to display a message as `Sending...` before the 20 | server knows it exists. This is usually where LiveView or HTMLX will recommend 21 | you sprinkle some Javascript. Lustre has an easier time dealing with this, but 22 | it can become quite cumbersome to manage the different HTTP requests and 23 | websocket connections. 24 | 25 | This is where Omnimessage comes in. When paired with a properly set up client 26 | (see [omnimessage_luster](https://hexdocs.pm/omnimessage_lustre)), all you need 27 | to do to communicate is handle Lustre messages. 28 | 29 | You can do that via websockets: 30 | 31 | ```gleam 32 | import omnimessage_server as omniserver 33 | 34 | case request.path_segments(req), req.method { 35 | ["omni-ws"], http.Get -> 36 | omniserver.mist_websocket_pipe( 37 | req, 38 | // this is for encoding/decoding, supplied by you 39 | encoder_decoder(), 40 | // message handler, recieves a message from the client and whatever 41 | // it returns will be sent back to the client 42 | handler, 43 | // error handler 44 | fn(_) { Nil }, 45 | ) 46 | } 47 | ``` 48 | via HTTP: 49 | 50 | ```gleam 51 | import omnimessage_server as omniserver 52 | 53 | fn wisp_handler(req, ctx) { 54 | use <- cors_middleware(req) 55 | use <- static_middleware(req) 56 | 57 | // For handling HTTP transports 58 | use <- omniserver.wisp_http_middleware( 59 | req, 60 | "/omni-http", 61 | encoder_decoder(), 62 | handler, 63 | ) 64 | 65 | // ...rest of handler 66 | } 67 | ``` 68 | 69 | or via full-blown lustre server component, communicating via websockets: 70 | 71 | ```gleam 72 | import omnimessage_server as omniserver 73 | 74 | case request.path_segments(req), req.method { 75 | ["omni-ws"], http.Get -> 76 | // chat.app() is a special lustre server component that'll recieve 77 | // message form the client and can dispatch message to answer 78 | omniserver.mist_websocket_application(req, chat.app(), ctx, fn(_) { Nil }) 79 | } 80 | ``` 81 | 82 | 83 | Further documentation can be found at . 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Omnimessage 2 | 3 | **Seamless server communication using Lustre messages** 4 | 5 | [![Package Version](https://img.shields.io/hexpm/v/omnimessage_lustre)](https://hex.pm/packages/omnimessage_lustre) 6 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/omnimessage_lustre/) 7 | 8 | A main challenge of maintaining a client side application is state 9 | synchronization. Usually, a complete separation is ideal, where client state is 10 | managed exclusively at the client, and server state exclusively at the server. 11 | There's nuance to this take, but it's a good general rule to follow. 12 | 13 | [Lustre](https://hexdocs.pm/lustre/) exceles at this with having both the power 14 | of a full-blown single page application, and the flexibility of using server 15 | components for a LiveView/HTMX like solution to state fully owned by the server. 16 | 17 | This approach becomes brittle when the state is shared between both the client 18 | and server. In a chat app, for example, the messages are state owned by the 19 | server, but you still need to display a message as `Sending...` before the 20 | server knows it exists. This is usually where LiveView or HTMLX will recommend 21 | you sprinkle some Javascript. Lustre has an easier time dealing with this, but 22 | it can become quite cumbersome to manage the different HTTP requests and 23 | websocket connections. 24 | 25 | This is where Omnimessage comes in. When a lustre application is paired with a 26 | properly set up server, they can communicate by dispatching messages in Lustre. 27 | 28 | ```gleam 29 | import omnimessage_lustre as omniclient 30 | 31 | pub fn chat_component() { 32 | // Instead of lustre.component, use: 33 | omniclient.component( 34 | init, 35 | update, 36 | view, 37 | dict.new(), 38 | // this is for encoding/decoding, supplied by you 39 | encoder_decoder, 40 | // this transfers the encoded messages 41 | transports.websocket("http://localhost:8000/omni-app-ws"), 42 | TransportState, 43 | ) 44 | } 45 | 46 | // Divide you messages wisely: 47 | pub type Msg { 48 | UserSendDraft 49 | UserUpdateDraftMessageContent(content: String) 50 | // Messages from the client 51 | ClientMessage(ClientMessage) 52 | // Messages from the server 53 | ServerMessage(ServerMessage) 54 | // Messages about transport health 55 | TransportState(transports.TransportState(json.DecodeError)) 56 | } 57 | 58 | 59 | fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 60 | case msg { 61 | // Merge strategy 62 | ServerMessage(shared.ServerUpsertMessages(server_messages)) -> { 63 | let messages = 64 | model.messages 65 | // Omnimessage shines when you're OK with server being source of truth 66 | |> dict.merge(server_messages) 67 | 68 | #(Model(..model, messages:), effect.none()) 69 | } 70 | // ...handle the rest of the messages 71 | } 72 | } 73 | 74 | // Then in your view, all you need to do is: 75 | html.form() 76 | |> event.on_submit(UserSendChat) 77 | // That message will go to both the client, that can use it to disaply the chat 78 | // in a sending state, and to the server, which can handle the new message 79 | // and reply with an updated, correct state. 80 | ``` 81 | 82 | Further documentation can be found at 83 | and 84 | -------------------------------------------------------------------------------- /omnimessage_lustre/README.md: -------------------------------------------------------------------------------- 1 | # omnimessage_lustre 2 | 3 | **Seamless server communication using Lustre messages** 4 | 5 | [![Package Version](https://img.shields.io/hexpm/v/omnimessage_lustre)](https://hex.pm/packages/omnimessage_lustre) 6 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/omnimessage_lustre/) 7 | 8 | A main challenge of maintaining a client side application is state 9 | synchronization. Usually, a complete separation is ideal, where client state is 10 | managed exclusively at the client, and server state exclusively at the server. 11 | There's nuance to this take, but it's a good general rule to follow. 12 | 13 | [Lustre](https://hexdocs.pm/lustre/) exceles at this with having both the power 14 | of a full-blown single page application, and the flexibility of using server 15 | components for a LiveView/HTMX like solution to state fully owned by the server. 16 | 17 | This approach becomes brittle when the state is shared between both the client 18 | and server. In a chat app, for example, the messages are state owned by the 19 | server, but you still need to display a message as `Sending...` before the 20 | server knows it exists. This is usually where LiveView or HTMLX will recommend 21 | you sprinkle some Javascript. Lustre has an easier time dealing with this, but 22 | it can become quite cumbersome to manage the different HTTP requests and 23 | websocket connections. 24 | 25 | This is where Omnimessage comes in. When paired with a properly set up server 26 | (see [omnimessage_server](https://hexdocs.pm/omnimessage_server)), all you need 27 | to do to communicate is dispatch messages in Lustre: 28 | 29 | ```gleam 30 | import omnimessage_lustre as omniclient 31 | 32 | pub fn chat_component() { 33 | // Instead of lustre.component, use: 34 | omniclient.component( 35 | init, 36 | update, 37 | view, 38 | dict.new(), 39 | // this is for encoding/decoding, supplied by you 40 | encoder_decoder, 41 | // this transfers the encoded messages 42 | transports.websocket("http://localhost:8000/omni-app-ws"), 43 | TransportState, 44 | ) 45 | } 46 | 47 | // Divide you messages wisely: 48 | pub type Msg { 49 | UserSendDraft 50 | UserUpdateDraftMessageContent(content: String) 51 | // Messages from the client 52 | ClientMessage(ClientMessage) 53 | // Messages from the server 54 | ServerMessage(ServerMessage) 55 | // Messages about transport health 56 | TransportState(transports.TransportState(json.DecodeError)) 57 | } 58 | 59 | 60 | fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 61 | case msg { 62 | // Merge strategy 63 | ServerMessage(shared.ServerUpsertMessages(server_messages)) -> { 64 | let messages = 65 | model.messages 66 | // Omnimessage shines when you're OK with server being source of truth 67 | |> dict.merge(server_messages) 68 | 69 | #(Model(..model, messages:), effect.none()) 70 | } 71 | // ...handle the rest of the messages 72 | } 73 | } 74 | 75 | // Then in your view, all you need to do is: 76 | html.form() 77 | |> event.on_submit(UserSendChat) 78 | // That message will go to both the client, that can use it to disaply the chat 79 | // in a sending state, and to the server, which can handle the new message 80 | // and reply with an updated, correct state. 81 | ``` 82 | 83 | Further documentation can be found at . 84 | 85 | ### Thanks & Acknowledgements 86 | 87 | - [kero/lustre_websocket](https://codeberg.org/kero/lustre_websocket) 88 | -------------------------------------------------------------------------------- /example/server/src/server/components/chat.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/result 3 | import lustre/effect 4 | import omnimessage/server as omniserver 5 | import shared.{type ClientMessage, type ServerMessage} 6 | import wisp 7 | 8 | import server/context.{type Context} 9 | 10 | pub fn app() { 11 | let encoder_decoder = 12 | omniserver.EncoderDecoder( 13 | fn(msg) { 14 | case msg { 15 | // Messages must be encodable 16 | ServerMessage(message) -> Ok(shared.encode_server_message(message)) 17 | // Return Error(Nil) for messages you don't want to send out 18 | _ -> Error(Nil) 19 | } 20 | }, 21 | fn(encoded_msg) { 22 | // Unsupported messages will cause TransportError(DecodeError(error)) 23 | // which you can ignore if you don't care about those messages 24 | shared.decode_client_message(encoded_msg) 25 | |> result.map(ClientMessage) 26 | }, 27 | ) 28 | 29 | // An omnimessage_server app has no view 30 | omniserver.application(init, update, encoder_decoder) 31 | } 32 | 33 | // MODEL ----------------------------------------------------------------------- 34 | 35 | fn get_messages(ctx: Context) { 36 | ctx 37 | |> context.get_chat_messages() 38 | } 39 | 40 | pub type Model { 41 | Model(messages: dict.Dict(String, shared.ChatMessage), ctx: Context) 42 | } 43 | 44 | // Unlike the session count component, this one is handed the entire app 45 | // context, which isn't abstracted away. This being a good or bad choice 46 | // depends on your speicifc application. 47 | fn init(ctx: Context) -> #(Model, effect.Effect(Msg)) { 48 | #( 49 | Model(messages: get_messages(ctx), ctx:), 50 | // 51 | effect.from(fn(dispatch) { 52 | context.add_chat_messages_listener( 53 | ctx, 54 | wisp.random_string(5), 55 | fn(messages) { 56 | messages 57 | |> shared.ServerUpsertChatMessages 58 | |> ServerMessage 59 | |> dispatch 60 | }, 61 | ) 62 | }), 63 | ) 64 | } 65 | 66 | // UPDATE ---------------------------------------------------------------------- 67 | 68 | pub type Msg { 69 | ClientMessage(ClientMessage) 70 | ServerMessage(ServerMessage) 71 | } 72 | 73 | pub fn update(model: Model, msg: Msg) { 74 | case msg { 75 | ClientMessage(shared.UserSendChatMessage(chat_msg)) -> #( 76 | model, 77 | effect.from(fn(dispatch) { 78 | shared.ChatMessage(..chat_msg, status: shared.Sent) 79 | |> context.add_chat_message(model.ctx, _) 80 | 81 | get_messages(model.ctx) 82 | |> shared.ServerUpsertChatMessages 83 | |> ServerMessage 84 | |> dispatch 85 | }), 86 | ) 87 | ClientMessage(shared.UserDeleteChatMessage(message_id)) -> #( 88 | model, 89 | effect.from(fn(dispatch) { 90 | model.ctx |> context.delete_chat_message(message_id) 91 | 92 | get_messages(model.ctx) 93 | |> shared.ServerUpsertChatMessages 94 | |> ServerMessage 95 | |> dispatch 96 | }), 97 | ) 98 | ClientMessage(shared.FetchChatMessages) -> #( 99 | model, 100 | effect.from(fn(dispatch) { 101 | get_messages(model.ctx) 102 | |> shared.ServerUpsertChatMessages 103 | |> ServerMessage 104 | |> dispatch 105 | }), 106 | ) 107 | ServerMessage(shared.ServerUpsertChatMessages(messages)) -> #( 108 | Model(..model, messages:), 109 | effect.none(), 110 | ) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /example/shared/src/shared.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict 2 | import gleam/json 3 | import gleam/list 4 | import gleam/string 5 | 6 | import birl 7 | import decode/zero 8 | import gluid 9 | 10 | pub type ClientMessage { 11 | UserSendChatMessage(ChatMessage) 12 | UserDeleteChatMessage(ChatMessageId) 13 | FetchChatMessages 14 | } 15 | 16 | pub fn encode_client_message(msg: ClientMessage) { 17 | case msg { 18 | UserSendChatMessage(chat_msg) -> [ 19 | json.int(0), 20 | chat_message_to_json(chat_msg), 21 | ] 22 | UserDeleteChatMessage(chat_msg_id) -> [ 23 | json.int(1), 24 | json.string(chat_msg_id), 25 | ] 26 | FetchChatMessages -> [json.int(2), json.null()] 27 | } 28 | |> json.preprocessed_array 29 | |> json.to_string 30 | } 31 | 32 | pub fn decode_client_message(str_msg: String) { 33 | let decoder = { 34 | use id <- zero.field(0, zero.int) 35 | 36 | case id { 37 | 0 -> { 38 | use chat_msg <- zero.field(1, chat_message_decoder()) 39 | zero.success(UserSendChatMessage(chat_msg)) 40 | } 41 | 1 -> { 42 | use message_id <- zero.field(1, zero.string) 43 | zero.success(UserDeleteChatMessage(message_id)) 44 | } 45 | 2 -> { 46 | zero.success(FetchChatMessages) 47 | } 48 | _ -> zero.failure(FetchChatMessages, "SharedMessage") 49 | } 50 | } 51 | 52 | str_msg 53 | |> json.decode(zero.run(_, decoder)) 54 | } 55 | 56 | pub type ServerMessage { 57 | ServerUpsertChatMessages(dict.Dict(String, ChatMessage)) 58 | } 59 | 60 | pub fn encode_server_message(msg: ServerMessage) { 61 | case msg { 62 | ServerUpsertChatMessages(messages) -> [ 63 | json.int(0), 64 | json.array(dict.values(messages), chat_message_to_json), 65 | ] 66 | } 67 | |> json.preprocessed_array 68 | |> json.to_string 69 | } 70 | 71 | pub fn decode_server_message(str_msg: String) { 72 | let decoder = { 73 | use id <- zero.field(0, zero.int) 74 | 75 | case id { 76 | 0 -> { 77 | use chat_msgs <- zero.field(1, zero.list(chat_message_decoder())) 78 | let chat_msgs = 79 | chat_msgs 80 | |> list.map(fn(chat_msg) { #(chat_msg.id, chat_msg) }) 81 | |> dict.from_list 82 | zero.success(ServerUpsertChatMessages(chat_msgs)) 83 | } 84 | _ -> zero.failure(ServerUpsertChatMessages(dict.new()), "ServerMessage") 85 | } 86 | } 87 | 88 | str_msg 89 | |> json.decode(zero.run(_, decoder)) 90 | } 91 | 92 | pub type Chat { 93 | Chat(messages: dict.Dict(String, ChatMessage)) 94 | } 95 | 96 | pub type ChatMessageId = 97 | String 98 | 99 | pub type ChatMessage { 100 | ChatMessage( 101 | id: ChatMessageId, 102 | content: String, 103 | status: MessageStatus, 104 | sent_at: birl.Time, 105 | ) 106 | } 107 | 108 | pub fn new_chat_msg(content, status) { 109 | ChatMessage( 110 | id: gluid.guidv4() |> string.lowercase(), 111 | content: content, 112 | status:, 113 | sent_at: birl.utc_now(), 114 | ) 115 | } 116 | 117 | fn chat_message_to_json(message: ChatMessage) { 118 | json.object([ 119 | #("id", message.id |> json.string), 120 | #("content", json.string(message.content)), 121 | #("status", json.int(encode_status(message.status))), 122 | #("sent_at", json.int(birl.to_unix(message.sent_at))), 123 | ]) 124 | } 125 | 126 | pub fn encode_chat_message(message: ChatMessage) { 127 | chat_message_to_json(message) 128 | |> json.to_string 129 | } 130 | 131 | fn chat_message_decoder() { 132 | use id <- zero.field("id", zero.string) 133 | use content <- zero.field("content", zero.string) 134 | use status <- zero.field("status", status_decoder()) 135 | use sent_at_unix <- zero.field("sent_at", zero.int) 136 | let sent_at = birl.from_unix(sent_at_unix) 137 | 138 | zero.success(ChatMessage(id:, content:, status:, sent_at:)) 139 | } 140 | 141 | pub fn decode_message(str_message: String) { 142 | json.decode(str_message, zero.run(_, chat_message_decoder())) 143 | } 144 | 145 | pub type MessageStatus { 146 | ClientError 147 | ServerError 148 | Sent 149 | Received 150 | Sending 151 | } 152 | 153 | fn status_decoder() { 154 | use decoded_string <- zero.then(zero.int) 155 | case decoded_string { 156 | 0 -> zero.success(ClientError) 157 | 1 -> zero.success(ServerError) 158 | 2 -> zero.success(Sent) 159 | 3 -> zero.success(Received) 160 | 4 -> zero.success(Sending) 161 | _ -> zero.failure(ClientError, "MessageStatus") 162 | } 163 | } 164 | 165 | fn encode_status(status: MessageStatus) { 166 | case status { 167 | ClientError -> 0 168 | ServerError -> 1 169 | Sent -> 2 170 | Received -> 3 171 | Sending -> 4 172 | } 173 | } 174 | 175 | pub fn status_string(status: MessageStatus) { 176 | case status { 177 | ClientError -> "Client Error" 178 | ServerError -> "Server Error" 179 | Sent -> "Sent" 180 | Received -> "Received" 181 | Sending -> "Sending" 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /omnimessage_lustre/src/omnimessage/lustre/internal/transports/websocket.gleam: -------------------------------------------------------------------------------- 1 | import gleam/option.{None, Some} 2 | import gleam/result 3 | import gleam/uri.{type Uri, Uri} 4 | 5 | pub type WebSocket 6 | 7 | pub type WebSocketCloseReason { 8 | // 1000 9 | Normal(code: Int, reason: String) 10 | // 1001 11 | GoingAway(code: Int, reason: String) 12 | // 1002 13 | ProtocolError(code: Int, reason: String) 14 | // 1003 15 | UnexpectedTypeOfData(code: Int, reason: String) 16 | // 1004 Reserved 17 | // 1005 18 | NoCodeFromServer(code: Int, reason: String) 19 | // 1006, no close frame 20 | AbnormalClose(code: Int, reason: String) 21 | // 1007 22 | IncomprehensibleFrame(code: Int, reason: String) 23 | // 1008 24 | PolicyViolated(code: Int, reason: String) 25 | // 1009 26 | MessageTooBig(code: Int, reason: String) 27 | // 1010 28 | FailedExtensionNegotation(code: Int, reason: String) 29 | // 1011 30 | UnexpectedFailure(code: Int, reason: String) 31 | // 1012 32 | ServiceRestart(code: Int, reason: String) 33 | // 1013 34 | TryAgainLater(code: Int, reason: String) 35 | // 1014 36 | BadGateway(code: Int, reason: String) 37 | // 1015 38 | FailedTLSHandshake(code: Int, reason: String) 39 | // unlisted 40 | OtherCloseReason(code: Int, reason: String) 41 | } 42 | 43 | fn parse_reason(code: Int, reason: String) -> WebSocketCloseReason { 44 | case code { 45 | 1000 -> Normal(code, reason) 46 | 1001 -> GoingAway(code, reason) 47 | 1002 -> ProtocolError(code, reason) 48 | 1003 -> UnexpectedTypeOfData(code, reason) 49 | 1005 -> NoCodeFromServer(code, reason) 50 | 1006 -> AbnormalClose(code, reason) 51 | 1007 -> IncomprehensibleFrame(code, reason) 52 | 1008 -> PolicyViolated(code, reason) 53 | 1009 -> MessageTooBig(code, reason) 54 | 1010 -> FailedExtensionNegotation(code, reason) 55 | 1011 -> UnexpectedFailure(code, reason) 56 | 1012 -> ServiceRestart(code, reason) 57 | 1013 -> TryAgainLater(code, reason) 58 | 1014 -> BadGateway(code, reason) 59 | 1015 -> FailedTLSHandshake(code, reason) 60 | _ -> OtherCloseReason(code, reason) 61 | } 62 | } 63 | 64 | pub type WebSocketError { 65 | InvalidUrl(message: String) 66 | UnsupportedEnvironment(message: String) 67 | } 68 | 69 | pub type WebSocketEvent { 70 | OnOpen(WebSocket) 71 | OnTextMessage(String) 72 | OnBinaryMessage(BitArray) 73 | OnClose(WebSocketCloseReason) 74 | } 75 | 76 | /// Initialize a websocket. These constructs are fully asynchronous, so you must provide a wrapper 77 | /// that takes a `WebSocketEvent` and turns it into a lustre message of your application. 78 | /// If the path given is a URL, that is used. 79 | /// If the path is an absolute path, host and port are taken from 80 | /// document.URL, and scheme will become ws for http and wss for https. 81 | /// If the path is a relative path, ditto, but the the path will be 82 | /// relative to the path from document.URL 83 | pub fn init(path: String) -> Result(WebSocket, WebSocketError) { 84 | case get_websocket_path(path) { 85 | Ok(url) -> do_init(url) 86 | _ -> Error(InvalidUrl("Invalid Url")) 87 | } 88 | } 89 | 90 | pub fn listen( 91 | ws: WebSocket, 92 | on_open on_open, 93 | on_text_message on_text_message, 94 | on_close on_close, 95 | ) { 96 | do_listen(ws, on_open:, on_text_message:, on_close: fn(code, reason) { 97 | parse_reason(code, reason) 98 | |> on_close 99 | }) 100 | } 101 | 102 | fn get_websocket_path(path) -> Result(String, Nil) { 103 | page_uri() 104 | |> result.try(do_get_websocket_path(path, _)) 105 | } 106 | 107 | fn do_get_websocket_path(path: String, page_uri: Uri) -> Result(String, Nil) { 108 | let path_uri = 109 | uri.parse(path) 110 | |> result.unwrap(Uri( 111 | scheme: None, 112 | userinfo: None, 113 | host: None, 114 | port: None, 115 | path: path, 116 | query: None, 117 | fragment: None, 118 | )) 119 | use merged <- result.try(uri.merge(page_uri, path_uri)) 120 | use merged_scheme <- result.try(option.to_result(merged.scheme, Nil)) 121 | use ws_scheme <- result.try(convert_scheme(merged_scheme)) 122 | Uri(..merged, scheme: Some(ws_scheme)) 123 | |> uri.to_string 124 | |> Ok 125 | } 126 | 127 | fn convert_scheme(scheme: String) -> Result(String, Nil) { 128 | case scheme { 129 | "https" -> Ok("wss") 130 | "http" -> Ok("ws") 131 | "ws" | "wss" -> Ok(scheme) 132 | _ -> Error(Nil) 133 | } 134 | } 135 | 136 | @external(javascript, "../../../../websocket.ffi.mjs", "ws_init") 137 | fn do_init(a: path) -> Result(WebSocket, WebSocketError) 138 | 139 | @external(javascript, "../../../../websocket.ffi.mjs", "ws_listen") 140 | fn do_listen( 141 | ws: WebSocket, 142 | on_open on_open: fn(WebSocket) -> Nil, 143 | on_text_message on_text_message: fn(String) -> Nil, 144 | on_close on_close: fn(Int, String) -> Nil, 145 | ) -> Nil 146 | 147 | @external(javascript, "../../../../websocket.ffi.mjs", "ws_send") 148 | pub fn send(ws ws: WebSocket, msg msg: String) -> Nil 149 | 150 | @external(javascript, "../../../../websocket.ffi.mjs", "ws_close") 151 | pub fn close(ws ws: WebSocket) -> Nil 152 | 153 | fn page_uri() -> Result(Uri, Nil) { 154 | do_get_page_url() 155 | |> uri.parse 156 | } 157 | 158 | @external(javascript, "../../../../websocket.ffi.mjs", "get_page_url") 159 | fn do_get_page_url() -> String 160 | -------------------------------------------------------------------------------- /omnimessage_server/manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 6 | { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, 7 | { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, 8 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 9 | { name = "gleam_crypto", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "917BC8B87DBD584830E3B389CBCAB140FFE7CB27866D27C6D0FB87A9ECF35602" }, 10 | { name = "gleam_erlang", version = "0.34.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" }, 11 | { name = "gleam_http", version = "4.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "0A62451FC85B98062E0907659D92E6A89F5F3C0FBE4AB8046C99936BF6F91DBC" }, 12 | { name = "gleam_json", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C55C5C2B318533A8072D221C5E06E5A75711C129E420DD1CE463342106012E5D" }, 13 | { name = "gleam_otp", version = "0.16.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" }, 14 | { name = "gleam_stdlib", version = "0.59.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F8FEE9B35797301994B81AF75508CF87C328FE1585558B0FFD188DC2B32EAA95" }, 15 | { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 16 | { name = "gleeunit", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, 17 | { name = "glisten", version = "7.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "1A53CF9FB3231A93FF7F1BD519A43DC968C1722F126CDD278403A78725FC5189" }, 18 | { name = "gramps", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "59194B3980110B403EE6B75330DB82CDE05FC8138491C2EAEACBC7AAEF30B2E8" }, 19 | { name = "houdini", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, 20 | { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 21 | { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 22 | { name = "lustre", version = "5.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "0BB69D69A9E75E675AA2C32A4A0E5086041D037829FC8AD385BA6A59E45A60A2" }, 23 | { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 24 | { name = "mist", version = "4.0.7", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "F7D15A1E3232E124C7CE31900253633434E59B34ED0E99F273DEE61CDB573CDD" }, 25 | { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 26 | { name = "simplifile", version = "2.2.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "C88E0EE2D509F6D86EB55161D631657675AA7684DAB83822F7E59EB93D9A60E3" }, 27 | { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 28 | { name = "wisp", version = "1.6.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "AE1C568FE30718C358D3B37666DF0A0743ECD96094AD98C9F4921475075F660A" }, 29 | ] 30 | 31 | [requirements] 32 | gleam_erlang = { version = ">= 0.30.0 and < 1.0.0" } 33 | gleam_http = { version = ">= 4.0.0 and < 5.0.0" } 34 | gleam_otp = { version = ">= 0.14.1 and < 1.0.0" } 35 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 36 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 37 | lustre = { version = ">= 5.0.0 and < 6.0.0" } 38 | mist = { version = ">= 4.0.0 and < 5.0.0" } 39 | wisp = { version = ">= 1.2.0 and < 2.0.0" } 40 | -------------------------------------------------------------------------------- /omnimessage_lustre/src/omnimessage/lustre.gleam: -------------------------------------------------------------------------------- 1 | /// omnimessage/lustre is the collection of tools for creating Lustre 2 | /// applications that are able to automatically send messages to a server. 3 | /// 4 | /// This allows you to seamlessly talk to a remote using normal Lustre 5 | /// messages, and handle replies as such. 6 | /// 7 | /// While most commonly this happens in a browser, omnimessage/lustre can run on 8 | /// erlang servers as well. The rule of thumb is if it initiates the connection, 9 | /// it's a client. If it responds to a connection request, it's a server. 10 | /// 11 | import lustre 12 | import lustre/effect 13 | 14 | import omnimessage/lustre/transports.{type TransportState} 15 | 16 | /// Holds decode and encode functions for omnimessage messages. Decode errors 17 | /// will be called back for you to handle, while Encode errors are interpreted 18 | /// as "skip this message" -- no error will be raised for them and they won't 19 | /// be sent over. 20 | /// 21 | /// Since an `EncoderDecoder` is expected to receive the whole message type of 22 | /// an application, but usually will ignore messages that aren't shared, it's 23 | /// best to define it as a thin wrapper around shared encoders/decoders: 24 | /// 25 | /// ```gleam 26 | /// // Holds shared message types, encoders and decoders 27 | /// import shared 28 | /// 29 | /// let encoder_decoder = 30 | /// EncoderDecoder( 31 | /// fn(msg) { 32 | /// case msg { 33 | /// // Messages must be encodable 34 | /// ClientMessage(message) -> Ok(shared.encode_client_message(message)) 35 | /// // Return Error(Nil) for messages you don't want to send out 36 | /// _ -> Error(Nil) 37 | /// } 38 | /// }, 39 | /// fn(encoded_msg) { 40 | /// // Unsupported messages will cause TransportError(DecodeError(error)) 41 | /// shared.decode_server_message(encoded_msg) 42 | /// |> result.map(ServerMessage) 43 | /// }, 44 | /// ) 45 | /// ``` 46 | /// 47 | pub type EncoderDecoder(msg, encoding, decode_error) { 48 | EncoderDecoder( 49 | encode: fn(msg) -> Result(encoding, Nil), 50 | decode: fn(encoding) -> Result(msg, decode_error), 51 | ) 52 | } 53 | 54 | /// Creates an omnimessage/lustre application. The extra parameters are: 55 | /// - `encoder_decoder` encodes and decodes messages 56 | /// - `transport` will transfer and recieve encoded messages. see 57 | /// `omnimessage/lustre/transports` for available ones 58 | /// - `transport_wrapper` a wrapper for your `Msg` type for transport status 59 | /// 60 | pub fn application( 61 | init, 62 | update, 63 | view, 64 | encoder_decoder, 65 | transport, 66 | transport_wrapper, 67 | ) { 68 | let #(omniinit, omniupdate) = 69 | compose(init, update, transport, encoder_decoder, transport_wrapper) 70 | 71 | lustre.application(omniinit, omniupdate, view) 72 | } 73 | 74 | /// Creates an omnimessage/lustre Lustre component. The extra parameters are: 75 | /// - `encoder_decoder` encodes and decodes messages 76 | /// - `transport` will transfer and recieve encoded messages. see 77 | /// `omnimessage/lustre/transports` for available ones 78 | /// - `transport_wrapper` a wrapper for your `Msg` type for transport status 79 | /// 80 | pub fn component( 81 | init, 82 | update, 83 | view, 84 | options, 85 | encoder_decoder, 86 | transport, 87 | transport_wrapper, 88 | ) { 89 | let #(omniinit, omniupdate) = 90 | compose(init, update, transport, encoder_decoder, transport_wrapper) 91 | 92 | lustre.component(omniinit, omniupdate, view, options) 93 | } 94 | 95 | fn compose(init, update, transport, encoder_decoder, meta_wrapper) { 96 | let coded_transport = transport |> to_coded_transport(encoder_decoder) 97 | 98 | let omniinit = fn(flags) { 99 | let transport_effect = 100 | fn(dispatch) { 101 | coded_transport.listen(dispatch, fn(state) { 102 | dispatch(meta_wrapper(state)) 103 | }) 104 | 105 | Nil 106 | } 107 | |> effect.from 108 | 109 | let #(model, effect) = init(flags) 110 | 111 | #(model, effect.batch([effect, transport_effect])) 112 | } 113 | 114 | let omniupdate = fn(model: model, msg: msg) { 115 | let #(updated_model, effect) = update(model, msg) 116 | 117 | #( 118 | updated_model, 119 | effect.batch([ 120 | fn(dispatch) { 121 | coded_transport.send(msg, dispatch, fn(state) { 122 | dispatch(meta_wrapper(state)) 123 | }) 124 | } 125 | |> effect.from, 126 | effect, 127 | ]), 128 | ) 129 | } 130 | 131 | #(omniinit, omniupdate) 132 | } 133 | 134 | type CodedTransport(msg, decode_error) { 135 | CodedTransport( 136 | listen: fn(fn(msg) -> Nil, fn(TransportState(decode_error)) -> Nil) -> Nil, 137 | send: fn(msg, fn(msg) -> Nil, fn(TransportState(decode_error)) -> Nil) -> 138 | Nil, 139 | ) 140 | } 141 | 142 | fn new_handlers( 143 | on_message, 144 | on_state, 145 | encoder_decoder: EncoderDecoder(msg, encoding, decode_error), 146 | ) { 147 | transports.TransportHandlers( 148 | on_up: fn() { on_state(transports.TransportUp) }, 149 | on_down: fn(code, reason) { 150 | on_state(transports.TransportDown(code, reason)) 151 | }, 152 | on_message: fn(encoded_msg) { 153 | case 154 | encoded_msg 155 | |> encoder_decoder.decode 156 | { 157 | Ok(msg) -> on_message(msg) 158 | Error(decode_error) -> 159 | on_state( 160 | transports.TransportError(transports.DecodeError(decode_error)), 161 | ) 162 | } 163 | }, 164 | on_error: fn(error) { on_state(transports.TransportError(error)) }, 165 | ) 166 | } 167 | 168 | fn to_coded_transport( 169 | base: transports.Transport(encoding, decode_error), 170 | encoder_decoder: EncoderDecoder(msg, encoding, decode_error), 171 | ) -> CodedTransport(msg, decode_error) { 172 | CodedTransport( 173 | listen: fn(on_message, on_state) { 174 | base.listen(new_handlers(on_message, on_state, encoder_decoder)) 175 | }, 176 | send: fn(msg, on_message, on_state) { 177 | case encoder_decoder.encode(msg) { 178 | Ok(msg) -> 179 | base.send(msg, new_handlers(on_message, on_state, encoder_decoder)) 180 | // An encoding error means "skip this message" 181 | Error(_) -> Nil 182 | } 183 | }, 184 | ) 185 | } 186 | -------------------------------------------------------------------------------- /example/server/src/server/context.gleam: -------------------------------------------------------------------------------- 1 | /// NOTE! This is a very rudimentary PubSub for the purpose of demonstrating 2 | /// OmniMessage. In production, use a proper PubSub, remove listeners when 3 | /// requests are closed, etc. 4 | import carpenter/table 5 | import gleam/dict.{type Dict} 6 | import gleam/erlang/process 7 | import gleam/int 8 | import gleam/list 9 | import gleam/otp/actor 10 | import gleam/result 11 | 12 | import shared.{type Chat, Chat} 13 | 14 | // this is demo without users, so just a single chat: 15 | const chat_id = 1 16 | 17 | type Tables { 18 | Tables(chats: table.Set(Int, Chat), sessions_count: table.Set(Int, Int)) 19 | } 20 | 21 | pub type Context = 22 | process.Subject(Message) 23 | 24 | pub type SessionCountListener = 25 | fn(Int) -> Nil 26 | 27 | pub type ChatMessagesListener = 28 | fn(Dict(String, shared.ChatMessage)) -> Nil 29 | 30 | pub type Message { 31 | GetChatMessages(to: process.Subject(Dict(String, shared.ChatMessage))) 32 | AddChatMessage(shared.ChatMessage) 33 | DeleteChatMessage(shared.ChatMessageId) 34 | IncremenetSessionCount 35 | DecremenetSessionCount 36 | AddSessionCountListener(String, SessionCountListener) 37 | AddChatMessagesListener(String, ChatMessagesListener) 38 | Shutdown 39 | } 40 | 41 | fn get_sessions_count(tables: Tables) { 42 | case 43 | tables.sessions_count 44 | |> table.lookup(chat_id) 45 | |> list.first 46 | { 47 | Ok(entry) -> { 48 | entry.1 49 | } 50 | Error(_) -> 0 51 | } 52 | } 53 | 54 | fn do_get_chat_messages(tables: Tables) { 55 | case 56 | tables.chats 57 | |> table.lookup(chat_id) 58 | |> list.first 59 | { 60 | Ok(entry) -> { entry.1 }.messages 61 | Error(_) -> dict.new() 62 | } 63 | } 64 | 65 | type ContextState { 66 | ContextState( 67 | tables: Tables, 68 | session_count_listeners: Dict(String, SessionCountListener), 69 | chat_msgs_listeners: Dict(String, ChatMessagesListener), 70 | ) 71 | } 72 | 73 | fn handle_message( 74 | message: Message, 75 | state: ContextState, 76 | ) -> actor.Next(Message, ContextState) { 77 | case message { 78 | Shutdown -> actor.Stop(process.Normal) 79 | 80 | GetChatMessages(to) -> { 81 | do_get_chat_messages(state.tables) 82 | |> process.send(to, _) 83 | 84 | actor.continue(state) 85 | } 86 | 87 | AddChatMessage(chat_msg) -> { 88 | let messages = 89 | do_get_chat_messages(state.tables) 90 | |> dict.insert(chat_msg.id, chat_msg) 91 | 92 | state.tables.chats |> table.insert([#(chat_id, Chat(messages))]) 93 | 94 | state.chat_msgs_listeners 95 | |> dict.each(fn(_, listener) { listener(messages) }) 96 | 97 | actor.continue(state) 98 | } 99 | 100 | DeleteChatMessage(chat_msg_id) -> { 101 | let messages = 102 | do_get_chat_messages(state.tables) |> dict.delete(chat_msg_id) 103 | 104 | state.tables.chats |> table.insert([#(chat_id, Chat(messages))]) 105 | 106 | state.chat_msgs_listeners 107 | |> dict.each(fn(_, listener) { listener(messages) }) 108 | 109 | actor.continue(state) 110 | } 111 | 112 | IncremenetSessionCount -> { 113 | let new_count = get_sessions_count(state.tables) + 1 114 | 115 | state.tables.sessions_count |> table.insert([#(chat_id, new_count)]) 116 | 117 | state.session_count_listeners 118 | |> dict.each(fn(_, listener) { listener(new_count) }) 119 | 120 | actor.continue(state) 121 | } 122 | 123 | DecremenetSessionCount -> { 124 | let new_count = int.max(0, get_sessions_count(state.tables) - 1) 125 | 126 | state.tables.sessions_count |> table.insert([#(chat_id, new_count)]) 127 | 128 | state.session_count_listeners 129 | |> dict.each(fn(_, listener) { listener(new_count) }) 130 | 131 | actor.continue(state) 132 | } 133 | 134 | AddSessionCountListener(id, listener) -> { 135 | listener(get_sessions_count(state.tables)) 136 | 137 | actor.continue( 138 | ContextState( 139 | ..state, 140 | session_count_listeners: state.session_count_listeners 141 | |> dict.insert(id, listener), 142 | ), 143 | ) 144 | } 145 | 146 | AddChatMessagesListener(id, listener) -> { 147 | listener(do_get_chat_messages(state.tables)) 148 | 149 | actor.continue( 150 | ContextState( 151 | ..state, 152 | chat_msgs_listeners: state.chat_msgs_listeners 153 | |> dict.insert(id, listener), 154 | ), 155 | ) 156 | } 157 | } 158 | } 159 | 160 | pub fn new() { 161 | use chats <- result.try( 162 | table.build("chats") 163 | |> table.privacy(table.Public) 164 | |> table.write_concurrency(table.AutoWriteConcurrency) 165 | |> table.read_concurrency(True) 166 | |> table.decentralized_counters(True) 167 | |> table.compression(False) 168 | |> table.set, 169 | ) 170 | 171 | use sessions_count <- result.try( 172 | table.build("sessions_count") 173 | |> table.privacy(table.Public) 174 | |> table.write_concurrency(table.AutoWriteConcurrency) 175 | |> table.read_concurrency(True) 176 | |> table.decentralized_counters(True) 177 | |> table.compression(False) 178 | |> table.set, 179 | ) 180 | 181 | ContextState( 182 | tables: Tables(chats:, sessions_count:), 183 | session_count_listeners: dict.new(), 184 | chat_msgs_listeners: dict.new(), 185 | ) 186 | |> actor.start(handle_message) 187 | |> result.replace_error(Nil) 188 | } 189 | 190 | const timeout = 1000 191 | 192 | pub fn get_chat_messages(ctx: Context) { 193 | actor.call(ctx, GetChatMessages, timeout) 194 | } 195 | 196 | pub fn add_chat_message(ctx: Context, chat_msg: shared.ChatMessage) { 197 | process.send(ctx, AddChatMessage(chat_msg)) 198 | } 199 | 200 | pub fn delete_chat_message(ctx: Context, chat_msg_id: shared.ChatMessageId) { 201 | process.send(ctx, DeleteChatMessage(chat_msg_id)) 202 | } 203 | 204 | pub fn add_chat_messages_listener( 205 | ctx: Context, 206 | id: String, 207 | listener: ChatMessagesListener, 208 | ) { 209 | process.send(ctx, AddChatMessagesListener(id, listener)) 210 | } 211 | 212 | pub fn add_session_listener( 213 | ctx: Context, 214 | id: String, 215 | listener: SessionCountListener, 216 | ) { 217 | process.send(ctx, AddSessionCountListener(id, listener)) 218 | } 219 | 220 | pub fn increment_session_count(ctx: Context) { 221 | process.send(ctx, IncremenetSessionCount) 222 | } 223 | 224 | pub fn decrement_session_count(ctx: Context) { 225 | process.send(ctx, DecremenetSessionCount) 226 | } 227 | -------------------------------------------------------------------------------- /omnimessage_lustre/src/omnimessage/lustre/transports.gleam: -------------------------------------------------------------------------------- 1 | /// Transports tell an omnimessage/lustre application how to communicate with 2 | /// the server. 3 | /// 4 | /// You hand them to `omniclient.application()` or `omniclient.component()` 5 | /// alongside an `EncoderDecoder` that corresponds to the encoding they use. 6 | /// 7 | /// Various transports are available, but you can always write your own if 8 | /// something is missing. Underneath, a transport is just some callbacks for 9 | /// when you receive an already encoded message. 10 | /// 11 | /// Note that transports do not your support automatic reconnection on errors. 12 | /// 13 | import gleam/dict 14 | import gleam/fetch 15 | import gleam/http 16 | import gleam/http/request 17 | import gleam/http/response 18 | import gleam/javascript/promise 19 | import gleam/option 20 | import gleam/result 21 | 22 | import omnimessage/lustre/internal/transports/websocket 23 | 24 | /// This type represents the state messages sent to your Lustre application via 25 | /// the wrapper you gave on application creation. 26 | /// 27 | /// It allows you to do housekeeping such as init calls, online/offline 28 | /// inidcators, and debugging. 29 | /// 30 | pub type TransportState(decode_error) { 31 | TransportUp 32 | TransportDown(code: Int, message: String) 33 | TransportError(TransportError(decode_error)) 34 | } 35 | 36 | /// This represents an error in the transport itself (e.g, loss of connection), 37 | /// sent inside a `TransportError` record. 38 | /// 39 | /// Note that this isn't for error handling of your app logic, use omnimessage 40 | /// messages for that. 41 | /// 42 | pub type TransportError(decode_error) { 43 | InitError(message: String) 44 | DecodeError(decode_error) 45 | SendError(message: String) 46 | } 47 | 48 | /// Represents the handlers a transport uses for communication. Unless you're 49 | /// building a transport, you don't need to know about this. 50 | /// 51 | pub type TransportHandlers(encoding, decode_error) { 52 | TransportHandlers( 53 | on_up: fn() -> Nil, 54 | on_down: fn(Int, String) -> Nil, 55 | on_message: fn(encoding) -> Nil, 56 | on_error: fn(TransportError(decode_error)) -> Nil, 57 | ) 58 | } 59 | 60 | /// 61 | pub type Transport(encoding, decode_error) { 62 | Transport( 63 | listen: fn(TransportHandlers(encoding, decode_error)) -> Nil, 64 | send: fn(encoding, TransportHandlers(encoding, decode_error)) -> Nil, 65 | ) 66 | } 67 | 68 | @target(javascript) 69 | /// A websocket transport using text frames 70 | pub fn websocket(path: String) -> Transport(String, decode_error) { 71 | case websocket.init(path) { 72 | Ok(ws) -> { 73 | Transport( 74 | listen: fn(handlers) { 75 | websocket.listen( 76 | ws, 77 | on_open: fn(_) { handlers.on_up() }, 78 | on_text_message: handlers.on_message, 79 | on_close: fn(reason) { 80 | handlers.on_down(reason.code, reason.reason) 81 | }, 82 | ) 83 | }, 84 | send: fn(msg, _handlers) { websocket.send(ws, msg) }, 85 | ) 86 | } 87 | Error(error) -> 88 | Transport( 89 | listen: fn(handlers) { handlers.on_error(InitError(error.message)) }, 90 | send: fn(_msg, _handlers) { Nil }, 91 | ) 92 | } 93 | } 94 | 95 | fn prepare_http_request( 96 | path path: String, 97 | method method: option.Option(http.Method), 98 | headers headers: dict.Dict(String, String), 99 | encoded_msg encoded_msg: String, 100 | ) { 101 | let method = option.unwrap(method, http.Post) 102 | 103 | let assert Ok(req) = request.to(path) 104 | 105 | let req = 106 | req 107 | |> request.set_method(method) 108 | |> request.set_body(encoded_msg) 109 | |> request.set_header("content-encoding", "application/json") 110 | 111 | let req = { 112 | use req, key, value <- dict.fold(headers, req) 113 | request.set_header(req, key, value) 114 | req 115 | } 116 | 117 | req 118 | } 119 | 120 | fn handle_http_response( 121 | rest res: Result(response.Response(String), TransportError(decode_error)), 122 | handlers handlers: TransportHandlers(String, decode_error), 123 | ) { 124 | let encoded_msg = { 125 | use res <- result.try(res) 126 | 127 | case res.status >= 200 && res.status < 300 { 128 | True -> Ok(res.body) 129 | // TODO 130 | False -> Error(SendError("")) 131 | } 132 | } 133 | 134 | case encoded_msg { 135 | Ok(encoded_msg) -> handlers.on_message(encoded_msg) 136 | Error(error) -> handlers.on_error(error) 137 | } 138 | } 139 | 140 | @external(javascript, "../../omnimessage_lustre.ffi.mjs", "on_online_change") 141 | fn on_online_change(callback: fn(Bool) -> Nil) -> Bool 142 | 143 | @target(javascript) 144 | /// An http transport using text requests 145 | pub fn http( 146 | path path: String, 147 | method method: option.Option(http.Method), 148 | headers headers: dict.Dict(String, String), 149 | ) -> Transport(String, decode_error) { 150 | Transport( 151 | listen: fn(handlers) { 152 | let is_online = 153 | on_online_change(fn(is_online) { 154 | case is_online { 155 | True -> handlers.on_up() 156 | False -> handlers.on_down(0, "Offline") 157 | } 158 | }) 159 | 160 | case is_online { 161 | True -> handlers.on_up() 162 | False -> handlers.on_down(0, "Offline") 163 | } 164 | }, 165 | send: fn(encoded_msg: String, handlers) { 166 | let req = prepare_http_request(path:, method:, headers:, encoded_msg:) 167 | 168 | fetch.send(req) 169 | |> promise.await(fn(res) { 170 | case res { 171 | Ok(res) -> fetch.read_text_body(res) 172 | Error(error) -> promise.resolve(Error(error)) 173 | } 174 | }) 175 | |> promise.tap(fn(res) { 176 | result.map_error(res, fn(_) { SendError("") }) 177 | |> handle_http_response(handlers) 178 | }) 179 | 180 | Nil 181 | }, 182 | ) 183 | } 184 | // @target(erlang) 185 | // /// An http transport using text requests 186 | // pub fn http( 187 | // path path: String, 188 | // method method: option.Option(http.Method), 189 | // headers headers: dict.Dict(String, String), 190 | // ) -> Transport(String, decode_error) { 191 | // Transport( 192 | // listen: fn(handlers) { handlers.on_up() }, 193 | // send: fn(encoded_msg: String, handlers) { 194 | // let req = prepare_http_request(path:, method:, headers:, encoded_msg:) 195 | // 196 | // httpc.send(req) 197 | // |> result.map_error(fn(_) { SendError("") }) 198 | // |> handle_http_response(handlers) 199 | // 200 | // Nil 201 | // }, 202 | // ) 203 | // } 204 | -------------------------------------------------------------------------------- /example/client/src/client/chat.gleam: -------------------------------------------------------------------------------- 1 | import birl 2 | import gleam/dict 3 | import gleam/json 4 | import gleam/list 5 | import gleam/option.{type Option} 6 | import gleam/result 7 | import lustre/effect 8 | import lustre_pipes/attribute 9 | import lustre_pipes/element 10 | import lustre_pipes/element/html 11 | import lustre_pipes/event 12 | import lustre_pipes/server_component 13 | import omnimessage/lustre as omniclient 14 | import omnimessage/lustre/transports 15 | import plinth/browser/document 16 | import plinth/browser/element as plinth_element 17 | 18 | import shared.{type ChatMessage, type ClientMessage, type ServerMessage} 19 | 20 | // MAIN ------------------------------------------------------------------------ 21 | 22 | pub fn chat() { 23 | let encoder_decoder = 24 | omniclient.EncoderDecoder( 25 | fn(msg) { 26 | case msg { 27 | // Messages must be encodable 28 | ClientMessage(message) -> Ok(shared.encode_client_message(message)) 29 | // Return Error(Nil) for messages you don't want to send out 30 | _ -> Error(Nil) 31 | } 32 | }, 33 | fn(encoded_msg) { 34 | // Unsupported messages will cause TransportError(DecodeError(error)) 35 | shared.decode_server_message(encoded_msg) 36 | |> result.map(ServerMessage) 37 | }, 38 | ) 39 | 40 | omniclient.component( 41 | init, 42 | update, 43 | view, 44 | [], 45 | encoder_decoder, 46 | transports.websocket("http://localhost:8000/omni-app-ws"), 47 | // transports.websocket("http://localhost:8000/omni-pipe-ws"), 48 | // transports.http("http://localhost:8000/omni-http", option.None, dict.new()), 49 | TransportState, 50 | ) 51 | } 52 | 53 | // MODEL ----------------------------------------------------------------------- 54 | 55 | pub type Model { 56 | Model(chat_msgs: dict.Dict(String, ChatMessage), draft_content: String) 57 | } 58 | 59 | fn init(_initial_model: Option(Model)) -> #(Model, effect.Effect(Msg)) { 60 | #(Model(dict.new(), draft_content: ""), effect.none()) 61 | } 62 | 63 | // UPDATE ---------------------------------------------------------------------- 64 | 65 | pub type Msg { 66 | UserSendDraft 67 | UserScrollToLatest 68 | UserUpdateDraftContent(String) 69 | ClientMessage(ClientMessage) 70 | ServerMessage(ServerMessage) 71 | TransportState(transports.TransportState(json.DecodeError)) 72 | } 73 | 74 | fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 75 | case msg { 76 | // Good old UI 77 | UserUpdateDraftContent(content) -> #( 78 | Model(..model, draft_content: content), 79 | effect.none(), 80 | ) 81 | UserSendDraft -> { 82 | #( 83 | Model(..model, draft_content: ""), 84 | effect.from(fn(dispatch) { 85 | shared.new_chat_msg(model.draft_content, shared.Sending) 86 | |> shared.UserSendChatMessage 87 | |> ClientMessage 88 | |> dispatch 89 | }), 90 | ) 91 | } 92 | UserScrollToLatest -> #(model, scroll_to_latest_message()) 93 | // Shared messages 94 | ClientMessage(shared.UserSendChatMessage(chat_msg)) -> { 95 | let chat_msgs = 96 | model.chat_msgs 97 | |> dict.insert(chat_msg.id, chat_msg) 98 | 99 | #(Model(..model, chat_msgs:), scroll_to_latest_message()) 100 | } 101 | // The rest of the ClientMessages are exlusively handled by the server 102 | ClientMessage(_) -> { 103 | #(model, effect.none()) 104 | } 105 | // Merge strategy 106 | ServerMessage(shared.ServerUpsertChatMessages(server_messages)) -> { 107 | let chat_msgs = 108 | model.chat_msgs 109 | // Omnimessage shines when you're OK with server being source of truth 110 | |> dict.merge(server_messages) 111 | 112 | #(Model(..model, chat_msgs:), effect.none()) 113 | } 114 | // State handlers - use for initialization, debug, online/offline indicator 115 | TransportState(transports.TransportUp) -> { 116 | #( 117 | model, 118 | effect.from(fn(dispatch) { 119 | dispatch(ClientMessage(shared.FetchChatMessages)) 120 | }), 121 | ) 122 | } 123 | TransportState(transports.TransportDown(_, _)) -> { 124 | // Use this for debugging, online/offline indicator 125 | #(model, effect.none()) 126 | } 127 | TransportState(transports.TransportError(_)) -> { 128 | // Use this for debugging, online/offline indicator 129 | #(model, effect.none()) 130 | } 131 | } 132 | } 133 | 134 | const msgs_container_id = "chat-msgs" 135 | 136 | fn scroll_to_latest_message() { 137 | effect.from(fn(_dispatch) { 138 | let _ = 139 | document.get_element_by_id(msgs_container_id) 140 | |> result.then(fn(container) { 141 | plinth_element.scroll_height(container) 142 | |> plinth_element.set_scroll_top(container, _) 143 | Ok(Nil) 144 | }) 145 | 146 | Nil 147 | }) 148 | } 149 | 150 | // VIEW ------------------------------------------------------------------------ 151 | 152 | fn chat_message_element(chat_msg: ChatMessage) { 153 | html.div() 154 | |> element.children([ 155 | html.p() 156 | |> element.text_content( 157 | shared.status_string(chat_msg.status) <> ": " <> chat_msg.content, 158 | ), 159 | ]) 160 | } 161 | 162 | fn sort_chat_messages(chat_msgs: List(ChatMessage)) { 163 | use msg_a, msg_b <- list.sort(chat_msgs) 164 | birl.compare(msg_a.sent_at, msg_b.sent_at) 165 | } 166 | 167 | fn view(model: Model) -> element.Element(Msg) { 168 | let sorted_chat_msgs = 169 | model.chat_msgs 170 | |> dict.values 171 | |> sort_chat_messages 172 | 173 | html.div() 174 | |> attribute.class("h-full flex flex-col justify-center items-center gap-y-5") 175 | |> element.children([ 176 | html.div() 177 | |> attribute.class("flex justify-center") 178 | |> element.children([ 179 | server_component.element() 180 | |> server_component.route("/sessions-count") 181 | |> element.empty(), 182 | ]), 183 | html.div() 184 | |> attribute.id(msgs_container_id) 185 | |> attribute.class( 186 | "h-80 w-80 overflow-y-auto p-5 border border-gray-400 rounded-xl", 187 | ) 188 | |> element.keyed({ 189 | use chat_msg <- list.map(sorted_chat_msgs) 190 | #(chat_msg.id, chat_message_element(chat_msg)) 191 | }), 192 | html.form() 193 | |> attribute.class("w-80 flex gap-x-4") 194 | |> event.on_submit(fn(_) { UserSendDraft }) 195 | |> element.children([ 196 | html.input() 197 | |> event.on_input(UserUpdateDraftContent) 198 | |> attribute.type_("text") 199 | |> attribute.value(model.draft_content) 200 | |> attribute.class("flex-1 border border-gray-400 rounded-lg p-1.5") 201 | |> element.empty(), 202 | html.input() 203 | |> attribute.type_("submit") 204 | |> attribute.value("Send") 205 | |> attribute.class( 206 | "border border-gray-400 rounded-lg p-1.5 text-gray-700 font-bold", 207 | ) 208 | |> element.empty(), 209 | ]), 210 | ]) 211 | } 212 | -------------------------------------------------------------------------------- /example/server/manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "birl", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "2AC7BA26F998E3DFADDB657148BD5DDFE966958AD4D6D6957DD0D22E5B56C400" }, 6 | { name = "carpenter", version = "0.3.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "carpenter", source = "hex", outer_checksum = "7F5AF15A315CF32E8EDD0700BC1E6711618F8049AFE66DFCE82D1161B33F7F1B" }, 7 | { name = "decode", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "decode", source = "hex", outer_checksum = "9FFAD3F60600C6777072C3836B9FD965961D7C76C5D6007918AE0F82C1B21BE3" }, 8 | { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 9 | { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, 10 | { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, 11 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 12 | { name = "gleam_crypto", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "917BC8B87DBD584830E3B389CBCAB140FFE7CB27866D27C6D0FB87A9ECF35602" }, 13 | { name = "gleam_erlang", version = "0.34.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" }, 14 | { name = "gleam_http", version = "4.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "0A62451FC85B98062E0907659D92E6A89F5F3C0FBE4AB8046C99936BF6F91DBC" }, 15 | { name = "gleam_json", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C55C5C2B318533A8072D221C5E06E5A75711C129E420DD1CE463342106012E5D" }, 16 | { name = "gleam_otp", version = "0.16.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" }, 17 | { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 18 | { name = "gleam_stdlib", version = "0.59.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F8FEE9B35797301994B81AF75508CF87C328FE1585558B0FFD188DC2B32EAA95" }, 19 | { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 20 | { name = "gleeunit", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, 21 | { name = "glisten", version = "7.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "1A53CF9FB3231A93FF7F1BD519A43DC968C1722F126CDD278403A78725FC5189" }, 22 | { name = "gluid", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gluid", source = "hex", outer_checksum = "78B6111469E88E646BE0134712543A4131339161D522ADC54FBBAB2C0FFA19F4" }, 23 | { name = "gramps", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "59194B3980110B403EE6B75330DB82CDE05FC8138491C2EAEACBC7AAEF30B2E8" }, 24 | { name = "houdini", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, 25 | { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 26 | { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 27 | { name = "lustre", version = "5.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "0BB69D69A9E75E675AA2C32A4A0E5086041D037829FC8AD385BA6A59E45A60A2" }, 28 | { name = "lustre_pipes", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "lustre_pipes", source = "hex", outer_checksum = "9885D14B60293CD9A1398532D0B5E1B62EBA84F086004034198426757C7FD9D6" }, 29 | { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 30 | { name = "mist", version = "4.0.7", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "F7D15A1E3232E124C7CE31900253633434E59B34ED0E99F273DEE61CDB573CDD" }, 31 | { name = "omnimessage_server", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "lustre", "mist", "wisp"], source = "local", path = "../../omnimessage_server" }, 32 | { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 33 | { name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" }, 34 | { name = "shared", version = "0.1.0", build_tools = ["gleam"], requirements = ["birl", "decode", "gleam_json", "gleam_stdlib", "gluid"], source = "local", path = "../shared" }, 35 | { name = "simplifile", version = "2.2.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "C88E0EE2D509F6D86EB55161D631657675AA7684DAB83822F7E59EB93D9A60E3" }, 36 | { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 37 | { name = "wisp", version = "1.6.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "AE1C568FE30718C358D3B37666DF0A0743ECD96094AD98C9F4921475075F660A" }, 38 | ] 39 | 40 | [requirements] 41 | carpenter = { version = ">= 0.3.1 and < 1.0.0" } 42 | decode = { version = ">= 1.1.0 and < 2.0.0" } 43 | filepath = { version = ">= 1.0.0 and < 2.0.0" } 44 | gleam_erlang = { version = ">= 0.28.0 and < 1.0.0" } 45 | gleam_http = { version = ">= 3.0.0 and < 5.0.0" } 46 | gleam_json = { version = ">= 2.0.0 and < 3.0.0" } 47 | gleam_otp = { version = ">= 0.13.0 and < 1.0.0" } 48 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 49 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 50 | lustre = { version = ">= 5.0.0 and < 6.0.0" } 51 | lustre_pipes = { version = ">= 0.3.0 and < 1.0.0" } 52 | mist = { version = ">= 4.0.0 and < 5.0.0" } 53 | omnimessage_server = { path = "../../omnimessage_server" } 54 | shared = { path = "../shared" } 55 | wisp = { version = ">= 1.2.0 and < 2.0.0" } 56 | -------------------------------------------------------------------------------- /omnimessage_server/src/omnimessage/server.gleam: -------------------------------------------------------------------------------- 1 | /// omnimessage/server is the collection of tools allowing you to handle 2 | /// connections from omnimessage/lustre applications. 3 | /// 4 | /// The rule of thumb is if it initiates the connection, it's a client. If it 5 | /// responds to a connection request, it's a server. 6 | /// 7 | /// While you could do this manually fairly simples, theese tools can help you 8 | /// get started quicker and provide a nicer quality-of-life. 9 | /// 10 | /// Do read the source of the functions to understand how to make your own 11 | /// customized solution. 12 | /// 13 | /// Currently only the erlang target is supported, but you could easily adapt 14 | /// the principles to the Node, Deno, or Bun targets. Or even to a runtime 15 | /// outside Gleam's ecosystem -- as long as you can send and receive encoded 16 | /// messages, you can communicate with omnimessage/lustre. 17 | /// 18 | import gleam/erlang/process.{type Subject} 19 | import gleam/function 20 | import gleam/http 21 | import gleam/http/request 22 | import gleam/option.{type Option, None, Some} 23 | import gleam/otp/actor 24 | import gleam/result 25 | import lustre/component 26 | import lustre/server_component 27 | 28 | import lustre 29 | import lustre/effect.{type Effect} 30 | import lustre/element 31 | import mist 32 | import wisp 33 | 34 | /// Holds decode and encode functions for omnimessage messages. Decode errors 35 | /// will be called back for you to handle, while Encode errors are interpreted 36 | /// as "skip this message" -- no error will be raised for them and they won't 37 | /// be sent over. 38 | /// 39 | /// Since an `EncoderDecoder` is expected to receive the whole message type of 40 | /// an application, but usually will ignore messages that aren't shared, it's 41 | /// best to define it as a thin wrapper around shared encoders/decoders: 42 | /// 43 | /// ```gleam 44 | /// // Holds shared message types, encoders and decoders 45 | /// import shared 46 | /// 47 | /// let encoder_decoder = 48 | /// EncoderDecoder( 49 | /// fn(msg) { 50 | /// case msg { 51 | /// // Messages must be encodable 52 | /// ClientMessage(message) -> Ok(shared.encode_client_message(message)) 53 | /// // Return Error(Nil) for messages you don't want to send out 54 | /// _ -> Error(Nil) 55 | /// } 56 | /// }, 57 | /// fn(encoded_msg) { 58 | /// // Unsupported messages will cause TransportError(DecodeError(error)) 59 | /// shared.decode_server_message(encoded_msg) 60 | /// |> result.map(ServerMessage) 61 | /// }, 62 | /// ) 63 | /// ``` 64 | /// 65 | pub type EncoderDecoder(msg, encoding, decode_error) { 66 | EncoderDecoder( 67 | encode: fn(msg) -> Result(encoding, Nil), 68 | decode: fn(encoding) -> Result(msg, decode_error), 69 | ) 70 | } 71 | 72 | /// A utility function for easily handling messages: 73 | /// 74 | /// ```gleam 75 | /// let out_msg = pipe(in_msg, encoder_decoder, handler) 76 | /// ``` 77 | /// 78 | pub fn pipe( 79 | msg: encoding, 80 | encoder_decoder: EncoderDecoder(msg, encoding, decode_error), 81 | handler: fn(msg) -> msg, 82 | ) -> Result(Option(encoding), decode_error) { 83 | msg 84 | |> encoder_decoder.decode 85 | |> result.map(handler) 86 | |> result.map(encoder_decoder.encode) 87 | // Encoding error means "skip this message" 88 | |> result.map(option.from_result) 89 | } 90 | 91 | /// 92 | pub opaque type App(start_args, model, msg, encoding, decode_error) { 93 | App( 94 | init: fn(start_args) -> #(model, Effect(msg)), 95 | update: fn(model, msg) -> #(model, Effect(msg)), 96 | options: Option(List(component.Option(msg))), 97 | encoder_decoder: EncoderDecoder(msg, encoding, decode_error), 98 | ) 99 | } 100 | 101 | /// This creates a version of a Lustre application that can be used in 102 | /// `omnimessage/server.start_actor` (see below). A view is not necessary, as 103 | /// this application will never render anything. 104 | /// 105 | pub fn application( 106 | init init: fn(start_args) -> #(model, Effect(msg)), 107 | update update: fn(model, msg) -> #(model, Effect(msg)), 108 | encoder_decoder encoder_decoder: EncoderDecoder(msg, encoding, decode_error), 109 | ) -> App(start_args, model, msg, encoding, decode_error) { 110 | App(init: init, update: update, options: None, encoder_decoder:) 111 | } 112 | 113 | pub fn component( 114 | init init: fn(start_args) -> #(model, Effect(msg)), 115 | update update: fn(model, msg) -> #(model, Effect(msg)), 116 | options options: List(component.Option(msg)), 117 | encoder_decoder encoder_decoder: EncoderDecoder(msg, encoding, decode_error), 118 | ) -> App(start_args, model, msg, encoding, decode_error) { 119 | App(init: init, update: update, options: Some(options), encoder_decoder:) 120 | } 121 | 122 | /// This is a beefed up version of `lustre.start_actor` that allows subscribing 123 | /// to messages dispatched inside the runtime. 124 | /// 125 | /// This is what enables using a Lustre server component for communication, 126 | /// powering `mist_websocket_application()` below. 127 | /// 128 | pub fn start_server_component( 129 | app: App(start_args, model, msg, encoding, decode_error), 130 | with_args start_args: start_args, 131 | with_listener listener: fn(msg) -> Nil, 132 | ) -> Result(lustre.Runtime(msg), lustre.Error) { 133 | let wrapped_update = fn(model, msg) { 134 | listener(msg) 135 | app.update(model, msg) 136 | } 137 | 138 | let view = fn(_model) { element.none() } 139 | let lustre_app = case app.options { 140 | None -> lustre.application(app.init, wrapped_update, view) 141 | Some(options) -> lustre.component(app.init, wrapped_update, view, options) 142 | } 143 | 144 | lustre.start_server_component(lustre_app, with: start_args) 145 | } 146 | 147 | /// A wisp middleware to automatically handle HTTP POST omnimessage messages. 148 | /// 149 | /// - `req` The wisp request 150 | /// - `path` The path to which messages are POSTed 151 | /// - `encoder_decoder` For encoding and decoding messages 152 | /// - `handler` For handling the incoming messages 153 | /// 154 | /// See a full example using this in the Readme or in the examples folder. 155 | /// 156 | pub fn wisp_http_middleware( 157 | req: wisp.Request, 158 | path: String, 159 | encoder_decoder, 160 | handler, 161 | fun: fn() -> wisp.Response, 162 | ) -> wisp.Response { 163 | case req.path == path, req.method { 164 | True, http.Post -> { 165 | use req_body <- wisp.require_string_body(req) 166 | 167 | case 168 | req_body 169 | |> pipe(encoder_decoder, handler) 170 | { 171 | Ok(Some(res_body)) -> wisp.response(200) |> wisp.string_body(res_body) 172 | Ok(None) -> wisp.response(200) 173 | Error(_) -> wisp.unprocessable_entity() 174 | } 175 | } 176 | _, _ -> fun() 177 | } 178 | } 179 | 180 | /// A mist websocket handler to automatically respond to omnimessage messages. 181 | /// 182 | /// Return this as a response to the websocket init request. 183 | /// 184 | /// - `req` The mist request 185 | /// - `encoder_decoder` For encoding and decoding messages 186 | /// - `handler` For handling the incoming messages 187 | /// - `on_error` For handling decode errors 188 | /// 189 | /// See a full example using this in the Readme or in the examples folder. 190 | /// 191 | pub fn mist_websocket_pipe( 192 | req: request.Request(mist.Connection), 193 | encoder_decoder: EncoderDecoder(msg, String, decode_error), 194 | handler: fn(msg) -> msg, 195 | on_error: fn(decode_error) -> Nil, 196 | ) { 197 | mist.websocket( 198 | request: req, 199 | on_init: fn(_conn) { #(None, None) }, 200 | handler: fn(runtime, conn, msg) { 201 | case msg { 202 | mist.Text(msg) -> { 203 | let _ = case pipe(msg, encoder_decoder, handler) { 204 | Ok(Some(encoded_msg)) -> mist.send_text_frame(conn, encoded_msg) 205 | Ok(None) -> Ok(Nil) 206 | Error(decode_error) -> Ok(on_error(decode_error)) 207 | } 208 | actor.continue(runtime) 209 | } 210 | 211 | mist.Binary(_) -> actor.continue(runtime) 212 | 213 | mist.Custom(_) -> actor.continue(runtime) 214 | 215 | mist.Closed | mist.Shutdown -> actor.Stop(process.Normal) 216 | } 217 | }, 218 | on_close: fn(_) { Nil }, 219 | ) 220 | } 221 | 222 | type WebsocketState(msg) { 223 | WebsocketState( 224 | runtime: lustre.Runtime(msg), 225 | omni_self: Subject(msg), 226 | lustre_self: Subject(server_component.ClientMessage(msg)), 227 | ) 228 | } 229 | 230 | /// A mist websocket handler to automatically respond to omnimessage messages 231 | /// via a Lustre server component. The server component can then be used 232 | /// similarly to one created by an `omnimessage/lustre` and handle the messages 233 | /// via update, dispatch, and effects. 234 | /// 235 | /// Return this as a response to the websocket init request. 236 | /// 237 | /// - `req` The mist request 238 | /// - `app` An application created with `omnimessage/server.application` 239 | /// - `flags` Flags to hand to the application's `init` 240 | /// - `on_error` For handling decode errors 241 | /// 242 | /// See a full example using this in the Readme or in the examples folder. 243 | /// 244 | pub fn mist_websocket_application( 245 | req: request.Request(mist.Connection), 246 | app: App(flags, model, msg, String, decode_error), 247 | flags: flags, 248 | on_error: fn(decode_error) -> Nil, 249 | ) { 250 | mist.websocket( 251 | request: req, 252 | on_init: fn(_conn) { 253 | let omni_self = process.new_subject() 254 | let lustre_self = process.new_subject() 255 | let assert Ok(runtime) = 256 | start_server_component(app, flags, process.send(omni_self, _)) 257 | 258 | let state = WebsocketState(runtime:, omni_self:, lustre_self:) 259 | 260 | #( 261 | state, 262 | option.Some( 263 | process.new_selector() 264 | |> process.selecting(omni_self, function.identity), 265 | ), 266 | ) 267 | }, 268 | handler: fn(state: WebsocketState(msg), conn, msg) { 269 | case msg { 270 | mist.Text(msg) -> { 271 | case app.encoder_decoder.decode(msg) { 272 | Ok(decoded_msg) -> 273 | lustre.send(state.runtime, lustre.dispatch(decoded_msg)) 274 | Error(decode_error) -> on_error(decode_error) 275 | } 276 | actor.continue(state) 277 | } 278 | mist.Binary(_) -> actor.continue(state) 279 | mist.Custom(msg) -> { 280 | // TODO: do we really want to crash this? 281 | let assert Ok(_) = case app.encoder_decoder.encode(msg) { 282 | Ok(msg) -> mist.send_text_frame(conn, msg) 283 | // Encode error is interpreted as "skip this message" 284 | Error(_) -> Ok(Nil) 285 | } 286 | 287 | actor.continue(state) 288 | } 289 | mist.Closed | mist.Shutdown -> { 290 | server_component.deregister_subject(state.lustre_self) 291 | |> lustre.send(to: state.runtime) 292 | 293 | actor.Stop(process.Normal) 294 | } 295 | } 296 | }, 297 | on_close: fn(state) { 298 | server_component.deregister_subject(state.lustre_self) 299 | |> lustre.send(to: state.runtime) 300 | }, 301 | ) 302 | } 303 | -------------------------------------------------------------------------------- /example/server/src/server/router.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process 2 | import gleam/function 3 | import gleam/http 4 | import gleam/http/request 5 | import gleam/http/response 6 | import gleam/json 7 | import gleam/option.{Some} 8 | import gleam/otp/actor 9 | import gleam/result 10 | import gleam/string_tree 11 | 12 | import lustre 13 | import lustre/server_component 14 | import lustre_pipes/attribute 15 | import lustre_pipes/element.{children, empty, text_content} 16 | import lustre_pipes/element/html 17 | 18 | import filepath 19 | import mist 20 | import wisp.{type Request, type Response} 21 | import wisp/wisp_mist 22 | 23 | import omnimessage/server as omniserver 24 | 25 | import server/components/chat 26 | import server/components/sessions_count 27 | import server/context.{type Context} 28 | import shared 29 | 30 | type Msg { 31 | ClientMessage(shared.ClientMessage) 32 | ServerMessage(shared.ServerMessage) 33 | Noop 34 | } 35 | 36 | fn encoder_decoder() -> omniserver.EncoderDecoder(Msg, String, json.DecodeError) { 37 | omniserver.EncoderDecoder( 38 | fn(msg) { 39 | case msg { 40 | // Messages must be encodable 41 | ServerMessage(message) -> Ok(shared.encode_server_message(message)) 42 | // Return Error(Nil) for messages you don't want to send out 43 | _ -> Error(Nil) 44 | } 45 | }, 46 | fn(encoded_msg) { 47 | // Unsupported messages will cause TransportError(DecodeError(error)) 48 | // which you can ignore if you don't care about those messages 49 | shared.decode_client_message(encoded_msg) 50 | |> result.map(ClientMessage) 51 | }, 52 | ) 53 | } 54 | 55 | fn handle(ctx: Context, msg: Msg) -> Msg { 56 | case msg { 57 | ClientMessage(shared.UserSendChatMessage(chat_msg)) -> { 58 | shared.ChatMessage(..chat_msg, status: shared.Sent) 59 | |> context.add_chat_message(ctx, _) 60 | 61 | context.get_chat_messages(ctx) 62 | |> shared.ServerUpsertChatMessages 63 | |> ServerMessage 64 | } 65 | ClientMessage(shared.UserDeleteChatMessage(message_id)) -> { 66 | ctx |> context.delete_chat_message(message_id) 67 | 68 | context.get_chat_messages(ctx) 69 | |> shared.ServerUpsertChatMessages 70 | |> ServerMessage 71 | } 72 | ClientMessage(shared.FetchChatMessages) -> { 73 | context.get_chat_messages(ctx) 74 | |> shared.ServerUpsertChatMessages 75 | |> ServerMessage 76 | } 77 | ServerMessage(_) | Noop -> Noop 78 | } 79 | } 80 | 81 | fn cors_middleware(req: Request, fun: fn() -> Response) -> Response { 82 | case req.method { 83 | http.Options -> { 84 | wisp.response(200) 85 | |> wisp.set_header("access-control-allow-origin", "*") 86 | |> wisp.set_header("access-control-allow-methods", "GET, POST, OPTIONS") 87 | |> wisp.set_header( 88 | "access-control-allow-headers", 89 | "Content-Type,Content-Encoding", 90 | ) 91 | } 92 | _ -> { 93 | fun() 94 | |> wisp.set_header("access-control-allow-origin", "*") 95 | |> wisp.set_header("access-control-allow-methods", "GET, POST, OPTIONS") 96 | |> wisp.set_header( 97 | "access-control-allow-headers", 98 | "Content-Type,Content-Encoding", 99 | ) 100 | } 101 | } 102 | } 103 | 104 | fn static_middleware(req: Request, fun: fn() -> Response) -> Response { 105 | let assert Ok(priv) = wisp.priv_directory("server") 106 | let priv_static = filepath.join(priv, "static") 107 | wisp.serve_static(req, under: "/priv/static", from: priv_static, next: fun) 108 | } 109 | 110 | fn wisp_handler(req, ctx) { 111 | use <- cors_middleware(req) 112 | use <- static_middleware(req) 113 | 114 | // For handling HTTP transports 115 | use <- omniserver.wisp_http_middleware( 116 | req, 117 | "/omni-http", 118 | encoder_decoder(), 119 | handle(ctx, _), 120 | ) 121 | 122 | case wisp.path_segments(req), req.method { 123 | // Home 124 | [], http.Get -> home() 125 | // 126 | // If you want extra control, this is how you'd do it without middleware: 127 | // 128 | // ["omni-http"], http.Post -> { 129 | // use req_body <- wisp.require_string_body(req) 130 | // 131 | // case 132 | // req_body 133 | // |> omniserver.pipe(encoder_decoder(), handle(ctx, _)) 134 | // { 135 | // Ok(Some(res_body)) -> wisp.response(200) |> wisp.string_body(res_body) 136 | // Ok(None) -> wisp.response(200) 137 | // Error(_) -> wisp.unprocessable_entity() 138 | // } 139 | // } 140 | _, _ -> wisp.not_found() 141 | } 142 | } 143 | 144 | type SessionCountState { 145 | SessionCountState( 146 | runtime: lustre.Runtime(sessions_count.Msg), 147 | self: process.Subject(server_component.ClientMessage(sessions_count.Msg)), 148 | ) 149 | } 150 | 151 | // Wisp doesn't support websockets yet 152 | pub fn mist_handler( 153 | req: request.Request(mist.Connection), 154 | ctx: Context, 155 | secret_key_base, 156 | ) -> response.Response(mist.ResponseData) { 157 | let wisp_mist_handler = 158 | fn(req) { wisp_handler(req, ctx) } 159 | |> wisp_mist.handler(secret_key_base) 160 | 161 | case request.path_segments(req), req.method { 162 | ["omni-app-ws"], http.Get -> 163 | omniserver.mist_websocket_application(req, chat.app(), ctx, fn(_) { Nil }) 164 | ["omni-pipe-ws"], http.Get -> 165 | omniserver.mist_websocket_pipe( 166 | req, 167 | encoder_decoder(), 168 | handle(ctx, _), 169 | fn(_) { Nil }, 170 | ) 171 | // 172 | // This is an example of manual websocket implementation, in case custom 173 | // functionality is needed, such as custom push logic. It is commented out 174 | // becuase the rudimentary PubSub implemented in `context` does not support 175 | // sending mist websocket messages, therefore the added listener will cause 176 | // a panic. I leave this code here for your reference, in case you want to 177 | // implement similar (yet working) logic. 178 | // 179 | // ["omni-manual-ws"], http.Get -> 180 | // mist.websocket( 181 | // request: req, 182 | // on_init: fn(conn) { 183 | // context.add_chat_messages_listener( 184 | // ctx, 185 | // wisp.random_string(5), 186 | // fn(chat_msgs) { 187 | // let encoded_msg = 188 | // chat_msgs 189 | // |> shared.ServerUpsertChatMessages 190 | // |> ServerMessage 191 | // |> encoder_decoder().encode 192 | // 193 | // let _ = case encoded_msg { 194 | // Ok(encoded_msg) -> mist.send_text_frame(conn, encoded_msg) 195 | // _ -> Ok(Nil) 196 | // } 197 | // 198 | // Nil 199 | // }, 200 | // ) 201 | // 202 | // #(None, None) 203 | // }, 204 | // handler: fn(runtime, conn, msg) { 205 | // case msg { 206 | // mist.Text(msg) -> { 207 | // let _ = case 208 | // omniserver.pipe(msg, encoder_decoder(), handle(ctx, _)) 209 | // { 210 | // Ok(Some(encoded_msg)) -> mist.send_text_frame(conn, encoded_msg) 211 | // Ok(None) -> Ok(Nil) 212 | // Error(decode_error) -> Ok(Nil) 213 | // } 214 | // actor.continue(runtime) 215 | // } 216 | // 217 | // mist.Binary(_) -> actor.continue(runtime) 218 | // 219 | // mist.Custom(_) -> actor.continue(runtime) 220 | // 221 | // mist.Closed | mist.Shutdown -> actor.Stop(process.Normal) 222 | // } 223 | // }, 224 | // on_close: fn(_) { Nil }, 225 | // ) 226 | ["sessions-count"], http.Get -> 227 | mist.websocket( 228 | req, 229 | on_init: fn(_) { 230 | let count_app = sessions_count.app() 231 | let assert Ok(runtime) = 232 | lustre.start_server_component( 233 | count_app, 234 | context.add_session_listener(ctx, wisp.random_string(5), _), 235 | ) 236 | 237 | context.increment_session_count(ctx) 238 | 239 | let self = process.new_subject() 240 | 241 | server_component.register_subject(self) 242 | |> lustre.send(to: runtime) 243 | 244 | #( 245 | SessionCountState(runtime:, self:), 246 | Some(process.selecting( 247 | process.new_selector(), 248 | self, 249 | function.identity, 250 | )), 251 | ) 252 | }, 253 | handler: fn(state: SessionCountState, conn, msg) { 254 | case msg { 255 | mist.Text(json) -> { 256 | let action = 257 | json.parse(json, server_component.runtime_message_decoder()) 258 | 259 | case action { 260 | Ok(action) -> lustre.send(state.runtime, action) 261 | Error(_) -> Nil 262 | } 263 | 264 | actor.continue(state) 265 | } 266 | mist.Custom(patch) -> { 267 | let assert Ok(_) = 268 | patch 269 | |> server_component.client_message_to_json 270 | |> json.to_string 271 | |> mist.send_text_frame(conn, _) 272 | 273 | actor.continue(state) 274 | } 275 | mist.Closed | mist.Shutdown -> { 276 | server_component.deregister_subject(state.self) 277 | |> lustre.send(to: state.runtime) 278 | 279 | actor.Stop(process.Normal) 280 | } 281 | mist.Binary(_) -> actor.continue(state) 282 | } 283 | }, 284 | on_close: fn(state: SessionCountState) { 285 | context.decrement_session_count(ctx) 286 | 287 | server_component.deregister_subject(state.self) 288 | |> lustre.send(to: state.runtime) 289 | 290 | lustre.send(state.runtime, lustre.shutdown()) 291 | }, 292 | ) 293 | 294 | _, _ -> wisp_mist_handler(req) 295 | } 296 | } 297 | 298 | fn page_scaffold( 299 | content: element.Element(a), 300 | init_json: String, 301 | ) -> element.Element(a) { 302 | html.html() 303 | |> attribute.attribute("lang", "en") 304 | |> attribute.class("h-full w-full overflow-hidden") 305 | |> children([ 306 | html.head() 307 | |> children([ 308 | html.meta() 309 | |> attribute.attribute("charset", "UTF-8") 310 | |> empty(), 311 | html.meta() 312 | |> attribute.name("viewport") 313 | |> attribute.attribute( 314 | "content", 315 | "width=device-width, initial-scale=1.0", 316 | ) 317 | |> empty(), 318 | html.title() 319 | |> text_content("OmniMessage"), 320 | html.link() 321 | |> attribute.href("/priv/static/client.css") 322 | |> attribute.rel("stylesheet") 323 | |> empty(), 324 | html.script() 325 | |> attribute.src("/priv/static/client.mjs") 326 | |> attribute.type_("module") 327 | |> empty(), 328 | server_component.script(), 329 | html.script() 330 | |> attribute.id("model") 331 | |> attribute.type_("module") 332 | |> text_content(init_json), 333 | ]), 334 | html.body() 335 | |> attribute.class("h-full w-full") 336 | |> children([ 337 | html.div() 338 | |> attribute.id("app") 339 | |> attribute.class("h-full w-full") 340 | |> children([content]), 341 | ]), 342 | ]) 343 | } 344 | 345 | fn home() -> Response { 346 | wisp.response(200) 347 | |> wisp.set_header("Content-Type", "text/html") 348 | |> wisp.html_body( 349 | // content 350 | html.div() 351 | |> empty() 352 | |> page_scaffold("") 353 | |> element.to_document_string_tree() 354 | // https://github.com/lustre-labs/lustre/issues/315 355 | |> string_tree.replace("\\n", ""), 356 | ) 357 | } 358 | -------------------------------------------------------------------------------- /example/client/manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 | { name = "birl", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "2AC7BA26F998E3DFADDB657148BD5DDFE966958AD4D6D6957DD0D22E5B56C400" }, 7 | { name = "conversation", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "conversation", source = "hex", outer_checksum = "103DF47463B8432AB713D6643DC17244B9C82E2B172A343150805129FE584A2F" }, 8 | { name = "decode", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "decode", source = "hex", outer_checksum = "9FFAD3F60600C6777072C3836B9FD965961D7C76C5D6007918AE0F82C1B21BE3" }, 9 | { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 10 | { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, 11 | { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, 12 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 13 | { name = "fs", version = "11.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "DD00A61D89EAC01D16D3FC51D5B0EB5F0722EF8E3C1A3A547CD086957F3260A9" }, 14 | { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, 15 | { name = "gleam_community_colour", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "FDD6AC62C6EC8506C005949A4FCEF032038191D5EAAEC3C9A203CD53AE956ACA" }, 16 | { name = "gleam_crypto", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "917BC8B87DBD584830E3B389CBCAB140FFE7CB27866D27C6D0FB87A9ECF35602" }, 17 | { name = "gleam_deque", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_deque", source = "hex", outer_checksum = "64D77068931338CF0D0CB5D37522C3E3CCA7CB7D6C5BACB41648B519CC0133C7" }, 18 | { name = "gleam_erlang", version = "0.34.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" }, 19 | { name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" }, 20 | { name = "gleam_http", version = "3.7.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8A70D2F70BB7CFEB5DF048A2183FFBA91AF6D4CF5798504841744A16999E33D2" }, 21 | { name = "gleam_httpc", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "1A38507AF26CACA145248733688703EADCB734EA971D4E34FB97B7613DECF132" }, 22 | { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, 23 | { name = "gleam_json", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C55C5C2B318533A8072D221C5E06E5A75711C129E420DD1CE463342106012E5D" }, 24 | { name = "gleam_otp", version = "0.16.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" }, 25 | { name = "gleam_package_interface", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_package_interface", source = "hex", outer_checksum = "C2D2CA097831D27A20DAFA62D44F5D1B12E8470272337FD133368ACA4969A317" }, 26 | { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 27 | { name = "gleam_stdlib", version = "0.59.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F8FEE9B35797301994B81AF75508CF87C328FE1585558B0FFD188DC2B32EAA95" }, 28 | { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 29 | { name = "gleeunit", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, 30 | { name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" }, 31 | { name = "glisten", version = "7.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "1A53CF9FB3231A93FF7F1BD519A43DC968C1722F126CDD278403A78725FC5189" }, 32 | { name = "gluid", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gluid", source = "hex", outer_checksum = "78B6111469E88E646BE0134712543A4131339161D522ADC54FBBAB2C0FFA19F4" }, 33 | { name = "gramps", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "59194B3980110B403EE6B75330DB82CDE05FC8138491C2EAEACBC7AAEF30B2E8" }, 34 | { name = "houdini", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, 35 | { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 36 | { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 37 | { name = "lustre", version = "5.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "0BB69D69A9E75E675AA2C32A4A0E5086041D037829FC8AD385BA6A59E45A60A2" }, 38 | { name = "lustre_dev_tools", version = "1.8.0", build_tools = ["gleam"], requirements = ["argv", "filepath", "fs", "gleam_community_ansi", "gleam_crypto", "gleam_deque", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_package_interface", "gleam_regexp", "gleam_stdlib", "glint", "glisten", "lustre", "mist", "repeatedly", "simplifile", "term_size", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "D3D46D0AEA049F40C52534FD11441E11BC24521D332F5203D9D0B6A96D909B25" }, 39 | { name = "lustre_pipes", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "lustre_pipes", source = "hex", outer_checksum = "9885D14B60293CD9A1398532D0B5E1B62EBA84F086004034198426757C7FD9D6" }, 40 | { name = "lustre_websocket", version = "0.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "lustre_websocket", source = "hex", outer_checksum = "7C986F711ACCF7F4EF4C24BDE0BE1D25D805A92ED3BFFE10BE61EBE1E92065D6" }, 41 | { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, 42 | { name = "mist", version = "4.0.6", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "ED319E5A7F2056E08340B6976EA5E717F3C3BB36056219AF826D280D9C077952" }, 43 | { name = "modem", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "EF6B6B187E9D6425DFADA3A1AC212C01C4F34913A135DA2FF9B963EEF324C1F7" }, 44 | { name = "omnimessage_lustre", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_http", "gleam_javascript", "gleam_stdlib", "lustre"], source = "local", path = "../../omnimessage_lustre" }, 45 | { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 46 | { name = "plinth", version = "0.5.9", build_tools = ["gleam"], requirements = ["conversation", "gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "9684C5D768F99B34537B48B100509389C45D2E7C045426E93ACB250993611724" }, 47 | { name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" }, 48 | { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, 49 | { name = "shared", version = "0.1.0", build_tools = ["gleam"], requirements = ["birl", "decode", "gleam_json", "gleam_stdlib", "gluid"], source = "local", path = "../shared" }, 50 | { name = "simplifile", version = "2.2.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "C88E0EE2D509F6D86EB55161D631657675AA7684DAB83822F7E59EB93D9A60E3" }, 51 | { name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" }, 52 | { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 53 | { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 54 | { name = "tom", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0910EE688A713994515ACAF1F486A4F05752E585B9E3209D8F35A85B234C2719" }, 55 | { name = "wisp", version = "1.6.0", build_tools = ["gleam"], requirements = ["directories", "exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "AE1C568FE30718C358D3B37666DF0A0743ECD96094AD98C9F4921475075F660A" }, 56 | ] 57 | 58 | [requirements] 59 | birl = { version = ">= 1.7.1 and < 2.0.0" } 60 | decode = { version = ">= 1.1.0 and < 2.0.0" } 61 | gleam_json = { version = ">= 2.0.0 and < 3.0.0" } 62 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 63 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 64 | lustre = { version = ">= 5.0.0 and < 6.0.0" } 65 | lustre_dev_tools = { version = ">= 1.6.0 and < 2.0.0" } 66 | lustre_pipes = { version = ">= 0.3.0 and < 1.0.0" } 67 | lustre_websocket = { version = ">= 0.7.6 and < 1.0.0" } 68 | modem = { version = ">= 2.0.1 and < 3.0.0" } 69 | omnimessage_lustre = { path = "../../omnimessage_lustre" } 70 | plinth = { version = ">= 0.5.9 and < 1.0.0" } 71 | shared = { path = "../shared" } 72 | -------------------------------------------------------------------------------- /example/server/priv/static/client.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | 7. Disable tap highlights on iOS 36 | */ 37 | 38 | html, 39 | :host { 40 | line-height: 1.5; 41 | /* 1 */ 42 | -webkit-text-size-adjust: 100%; 43 | /* 2 */ 44 | -moz-tab-size: 4; 45 | /* 3 */ 46 | -o-tab-size: 4; 47 | tab-size: 4; 48 | /* 3 */ 49 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 50 | /* 4 */ 51 | font-feature-settings: normal; 52 | /* 5 */ 53 | font-variation-settings: normal; 54 | /* 6 */ 55 | -webkit-tap-highlight-color: transparent; 56 | /* 7 */ 57 | } 58 | 59 | /* 60 | 1. Remove the margin in all browsers. 61 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 62 | */ 63 | 64 | body { 65 | margin: 0; 66 | /* 1 */ 67 | line-height: inherit; 68 | /* 2 */ 69 | } 70 | 71 | /* 72 | 1. Add the correct height in Firefox. 73 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 74 | 3. Ensure horizontal rules are visible by default. 75 | */ 76 | 77 | hr { 78 | height: 0; 79 | /* 1 */ 80 | color: inherit; 81 | /* 2 */ 82 | border-top-width: 1px; 83 | /* 3 */ 84 | } 85 | 86 | /* 87 | Add the correct text decoration in Chrome, Edge, and Safari. 88 | */ 89 | 90 | abbr:where([title]) { 91 | -webkit-text-decoration: underline dotted; 92 | text-decoration: underline dotted; 93 | } 94 | 95 | /* 96 | Remove the default font size and weight for headings. 97 | */ 98 | 99 | h1, 100 | h2, 101 | h3, 102 | h4, 103 | h5, 104 | h6 { 105 | font-size: inherit; 106 | font-weight: inherit; 107 | } 108 | 109 | /* 110 | Reset links to optimize for opt-in styling instead of opt-out. 111 | */ 112 | 113 | a { 114 | color: inherit; 115 | text-decoration: inherit; 116 | } 117 | 118 | /* 119 | Add the correct font weight in Edge and Safari. 120 | */ 121 | 122 | b, 123 | strong { 124 | font-weight: bolder; 125 | } 126 | 127 | /* 128 | 1. Use the user's configured `mono` font-family by default. 129 | 2. Use the user's configured `mono` font-feature-settings by default. 130 | 3. Use the user's configured `mono` font-variation-settings by default. 131 | 4. Correct the odd `em` font sizing in all browsers. 132 | */ 133 | 134 | code, 135 | kbd, 136 | samp, 137 | pre { 138 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 139 | /* 1 */ 140 | font-feature-settings: normal; 141 | /* 2 */ 142 | font-variation-settings: normal; 143 | /* 3 */ 144 | font-size: 1em; 145 | /* 4 */ 146 | } 147 | 148 | /* 149 | Add the correct font size in all browsers. 150 | */ 151 | 152 | small { 153 | font-size: 80%; 154 | } 155 | 156 | /* 157 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 158 | */ 159 | 160 | sub, 161 | sup { 162 | font-size: 75%; 163 | line-height: 0; 164 | position: relative; 165 | vertical-align: baseline; 166 | } 167 | 168 | sub { 169 | bottom: -0.25em; 170 | } 171 | 172 | sup { 173 | top: -0.5em; 174 | } 175 | 176 | /* 177 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 178 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 179 | 3. Remove gaps between table borders by default. 180 | */ 181 | 182 | table { 183 | text-indent: 0; 184 | /* 1 */ 185 | border-color: inherit; 186 | /* 2 */ 187 | border-collapse: collapse; 188 | /* 3 */ 189 | } 190 | 191 | /* 192 | 1. Change the font styles in all browsers. 193 | 2. Remove the margin in Firefox and Safari. 194 | 3. Remove default padding in all browsers. 195 | */ 196 | 197 | button, 198 | input, 199 | optgroup, 200 | select, 201 | textarea { 202 | font-family: inherit; 203 | /* 1 */ 204 | font-feature-settings: inherit; 205 | /* 1 */ 206 | font-variation-settings: inherit; 207 | /* 1 */ 208 | font-size: 100%; 209 | /* 1 */ 210 | font-weight: inherit; 211 | /* 1 */ 212 | line-height: inherit; 213 | /* 1 */ 214 | color: inherit; 215 | /* 1 */ 216 | margin: 0; 217 | /* 2 */ 218 | padding: 0; 219 | /* 3 */ 220 | } 221 | 222 | /* 223 | Remove the inheritance of text transform in Edge and Firefox. 224 | */ 225 | 226 | button, 227 | select { 228 | text-transform: none; 229 | } 230 | 231 | /* 232 | 1. Correct the inability to style clickable types in iOS and Safari. 233 | 2. Remove default button styles. 234 | */ 235 | 236 | button, 237 | [type='button'], 238 | [type='reset'], 239 | [type='submit'] { 240 | -webkit-appearance: button; 241 | /* 1 */ 242 | background-color: transparent; 243 | /* 2 */ 244 | background-image: none; 245 | /* 2 */ 246 | } 247 | 248 | /* 249 | Use the modern Firefox focus style for all focusable elements. 250 | */ 251 | 252 | :-moz-focusring { 253 | outline: auto; 254 | } 255 | 256 | /* 257 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 258 | */ 259 | 260 | :-moz-ui-invalid { 261 | box-shadow: none; 262 | } 263 | 264 | /* 265 | Add the correct vertical alignment in Chrome and Firefox. 266 | */ 267 | 268 | progress { 269 | vertical-align: baseline; 270 | } 271 | 272 | /* 273 | Correct the cursor style of increment and decrement buttons in Safari. 274 | */ 275 | 276 | ::-webkit-inner-spin-button, 277 | ::-webkit-outer-spin-button { 278 | height: auto; 279 | } 280 | 281 | /* 282 | 1. Correct the odd appearance in Chrome and Safari. 283 | 2. Correct the outline style in Safari. 284 | */ 285 | 286 | [type='search'] { 287 | -webkit-appearance: textfield; 288 | /* 1 */ 289 | outline-offset: -2px; 290 | /* 2 */ 291 | } 292 | 293 | /* 294 | Remove the inner padding in Chrome and Safari on macOS. 295 | */ 296 | 297 | ::-webkit-search-decoration { 298 | -webkit-appearance: none; 299 | } 300 | 301 | /* 302 | 1. Correct the inability to style clickable types in iOS and Safari. 303 | 2. Change font properties to `inherit` in Safari. 304 | */ 305 | 306 | ::-webkit-file-upload-button { 307 | -webkit-appearance: button; 308 | /* 1 */ 309 | font: inherit; 310 | /* 2 */ 311 | } 312 | 313 | /* 314 | Add the correct display in Chrome and Safari. 315 | */ 316 | 317 | summary { 318 | display: list-item; 319 | } 320 | 321 | /* 322 | Removes the default spacing and border for appropriate elements. 323 | */ 324 | 325 | blockquote, 326 | dl, 327 | dd, 328 | h1, 329 | h2, 330 | h3, 331 | h4, 332 | h5, 333 | h6, 334 | hr, 335 | figure, 336 | p, 337 | pre { 338 | margin: 0; 339 | } 340 | 341 | fieldset { 342 | margin: 0; 343 | padding: 0; 344 | } 345 | 346 | legend { 347 | padding: 0; 348 | } 349 | 350 | ol, 351 | ul, 352 | menu { 353 | list-style: none; 354 | margin: 0; 355 | padding: 0; 356 | } 357 | 358 | /* 359 | Reset default styling for dialogs. 360 | */ 361 | 362 | dialog { 363 | padding: 0; 364 | } 365 | 366 | /* 367 | Prevent resizing textareas horizontally by default. 368 | */ 369 | 370 | textarea { 371 | resize: vertical; 372 | } 373 | 374 | /* 375 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 376 | 2. Set the default placeholder color to the user's configured gray 400 color. 377 | */ 378 | 379 | input::-moz-placeholder, textarea::-moz-placeholder { 380 | opacity: 1; 381 | /* 1 */ 382 | color: #9ca3af; 383 | /* 2 */ 384 | } 385 | 386 | input::placeholder, 387 | textarea::placeholder { 388 | opacity: 1; 389 | /* 1 */ 390 | color: #9ca3af; 391 | /* 2 */ 392 | } 393 | 394 | /* 395 | Set the default cursor for buttons. 396 | */ 397 | 398 | button, 399 | [role="button"] { 400 | cursor: pointer; 401 | } 402 | 403 | /* 404 | Make sure disabled buttons don't get the pointer cursor. 405 | */ 406 | 407 | :disabled { 408 | cursor: default; 409 | } 410 | 411 | /* 412 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 413 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 414 | This can trigger a poorly considered lint error in some tools but is included by design. 415 | */ 416 | 417 | img, 418 | svg, 419 | video, 420 | canvas, 421 | audio, 422 | iframe, 423 | embed, 424 | object { 425 | display: block; 426 | /* 1 */ 427 | vertical-align: middle; 428 | /* 2 */ 429 | } 430 | 431 | /* 432 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 433 | */ 434 | 435 | img, 436 | video { 437 | max-width: 100%; 438 | height: auto; 439 | } 440 | 441 | /* Make elements with the HTML hidden attribute stay hidden by default */ 442 | 443 | [hidden] { 444 | display: none; 445 | } 446 | 447 | *, ::before, ::after { 448 | --tw-border-spacing-x: 0; 449 | --tw-border-spacing-y: 0; 450 | --tw-translate-x: 0; 451 | --tw-translate-y: 0; 452 | --tw-rotate: 0; 453 | --tw-skew-x: 0; 454 | --tw-skew-y: 0; 455 | --tw-scale-x: 1; 456 | --tw-scale-y: 1; 457 | --tw-pan-x: ; 458 | --tw-pan-y: ; 459 | --tw-pinch-zoom: ; 460 | --tw-scroll-snap-strictness: proximity; 461 | --tw-gradient-from-position: ; 462 | --tw-gradient-via-position: ; 463 | --tw-gradient-to-position: ; 464 | --tw-ordinal: ; 465 | --tw-slashed-zero: ; 466 | --tw-numeric-figure: ; 467 | --tw-numeric-spacing: ; 468 | --tw-numeric-fraction: ; 469 | --tw-ring-inset: ; 470 | --tw-ring-offset-width: 0px; 471 | --tw-ring-offset-color: #fff; 472 | --tw-ring-color: rgb(59 130 246 / 0.5); 473 | --tw-ring-offset-shadow: 0 0 #0000; 474 | --tw-ring-shadow: 0 0 #0000; 475 | --tw-shadow: 0 0 #0000; 476 | --tw-shadow-colored: 0 0 #0000; 477 | --tw-blur: ; 478 | --tw-brightness: ; 479 | --tw-contrast: ; 480 | --tw-grayscale: ; 481 | --tw-hue-rotate: ; 482 | --tw-invert: ; 483 | --tw-saturate: ; 484 | --tw-sepia: ; 485 | --tw-drop-shadow: ; 486 | --tw-backdrop-blur: ; 487 | --tw-backdrop-brightness: ; 488 | --tw-backdrop-contrast: ; 489 | --tw-backdrop-grayscale: ; 490 | --tw-backdrop-hue-rotate: ; 491 | --tw-backdrop-invert: ; 492 | --tw-backdrop-opacity: ; 493 | --tw-backdrop-saturate: ; 494 | --tw-backdrop-sepia: ; 495 | } 496 | 497 | ::backdrop { 498 | --tw-border-spacing-x: 0; 499 | --tw-border-spacing-y: 0; 500 | --tw-translate-x: 0; 501 | --tw-translate-y: 0; 502 | --tw-rotate: 0; 503 | --tw-skew-x: 0; 504 | --tw-skew-y: 0; 505 | --tw-scale-x: 1; 506 | --tw-scale-y: 1; 507 | --tw-pan-x: ; 508 | --tw-pan-y: ; 509 | --tw-pinch-zoom: ; 510 | --tw-scroll-snap-strictness: proximity; 511 | --tw-gradient-from-position: ; 512 | --tw-gradient-via-position: ; 513 | --tw-gradient-to-position: ; 514 | --tw-ordinal: ; 515 | --tw-slashed-zero: ; 516 | --tw-numeric-figure: ; 517 | --tw-numeric-spacing: ; 518 | --tw-numeric-fraction: ; 519 | --tw-ring-inset: ; 520 | --tw-ring-offset-width: 0px; 521 | --tw-ring-offset-color: #fff; 522 | --tw-ring-color: rgb(59 130 246 / 0.5); 523 | --tw-ring-offset-shadow: 0 0 #0000; 524 | --tw-ring-shadow: 0 0 #0000; 525 | --tw-shadow: 0 0 #0000; 526 | --tw-shadow-colored: 0 0 #0000; 527 | --tw-blur: ; 528 | --tw-brightness: ; 529 | --tw-contrast: ; 530 | --tw-grayscale: ; 531 | --tw-hue-rotate: ; 532 | --tw-invert: ; 533 | --tw-saturate: ; 534 | --tw-sepia: ; 535 | --tw-drop-shadow: ; 536 | --tw-backdrop-blur: ; 537 | --tw-backdrop-brightness: ; 538 | --tw-backdrop-contrast: ; 539 | --tw-backdrop-grayscale: ; 540 | --tw-backdrop-hue-rotate: ; 541 | --tw-backdrop-invert: ; 542 | --tw-backdrop-opacity: ; 543 | --tw-backdrop-saturate: ; 544 | --tw-backdrop-sepia: ; 545 | } 546 | 547 | .container { 548 | width: 100%; 549 | } 550 | 551 | @media (min-width: 640px) { 552 | .container { 553 | max-width: 640px; 554 | } 555 | } 556 | 557 | @media (min-width: 768px) { 558 | .container { 559 | max-width: 768px; 560 | } 561 | } 562 | 563 | @media (min-width: 1024px) { 564 | .container { 565 | max-width: 1024px; 566 | } 567 | } 568 | 569 | @media (min-width: 1280px) { 570 | .container { 571 | max-width: 1280px; 572 | } 573 | } 574 | 575 | @media (min-width: 1536px) { 576 | .container { 577 | max-width: 1536px; 578 | } 579 | } 580 | 581 | .flex { 582 | display: flex; 583 | } 584 | 585 | .h-80 { 586 | height: 20rem; 587 | } 588 | 589 | .h-full { 590 | height: 100%; 591 | } 592 | 593 | .w-80 { 594 | width: 20rem; 595 | } 596 | 597 | .w-full { 598 | width: 100%; 599 | } 600 | 601 | .flex-1 { 602 | flex: 1 1 0%; 603 | } 604 | 605 | .flex-col { 606 | flex-direction: column; 607 | } 608 | 609 | .items-center { 610 | align-items: center; 611 | } 612 | 613 | .justify-center { 614 | justify-content: center; 615 | } 616 | 617 | .gap-x-4 { 618 | -moz-column-gap: 1rem; 619 | column-gap: 1rem; 620 | } 621 | 622 | .gap-y-5 { 623 | row-gap: 1.25rem; 624 | } 625 | 626 | .overflow-hidden { 627 | overflow: hidden; 628 | } 629 | 630 | .overflow-y-auto { 631 | overflow-y: auto; 632 | } 633 | 634 | .rounded-lg { 635 | border-radius: 0.5rem; 636 | } 637 | 638 | .rounded-xl { 639 | border-radius: 0.75rem; 640 | } 641 | 642 | .border { 643 | border-width: 1px; 644 | } 645 | 646 | .border-gray-400 { 647 | --tw-border-opacity: 1; 648 | border-color: rgb(156 163 175 / var(--tw-border-opacity)); 649 | } 650 | 651 | .p-1 { 652 | padding: 0.25rem; 653 | } 654 | 655 | .p-1\.5 { 656 | padding: 0.375rem; 657 | } 658 | 659 | .p-5 { 660 | padding: 1.25rem; 661 | } 662 | 663 | .font-bold { 664 | font-weight: 700; 665 | } 666 | 667 | .text-gray-700 { 668 | --tw-text-opacity: 1; 669 | color: rgb(55 65 81 / var(--tw-text-opacity)); 670 | } 671 | --------------------------------------------------------------------------------