├── .cargo
└── config.toml
├── static
├── clock.png
├── todos.png
├── counter.png
├── clock.css
├── counter.css
└── todos.css
├── examples
├── list.html
├── clock.html
├── todos.html
├── counter.html
├── README.md
├── counter.rs
├── clock.rs
└── todos.rs
├── .gitignore
├── rustfmt.toml
├── web
├── .gitignore
├── package.json
├── rollup.config.js
├── main.js
└── package-lock.json
├── LICENSE
├── src
├── csrf.rs
├── rendered
│ ├── dynamic.rs
│ ├── strip.rs
│ ├── diff.rs
│ └── builder.rs
├── manager.rs
├── template.rs
├── maud.rs
├── event_handler.rs
├── live_view.rs
├── lib.rs
├── rendered.rs
├── handler.rs
└── socket.rs
├── Cargo.toml
├── README.md
└── tests
└── diff.rs
/.cargo/config.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | target = "wasm32-wasi"
3 |
4 | [target.wasm32-wasi]
5 | runner = "lunatic"
6 |
--------------------------------------------------------------------------------
/static/clock.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lunatic-solutions/submillisecond-live-view/HEAD/static/clock.png
--------------------------------------------------------------------------------
/static/todos.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lunatic-solutions/submillisecond-live-view/HEAD/static/todos.png
--------------------------------------------------------------------------------
/examples/list.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/static/counter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lunatic-solutions/submillisecond-live-view/HEAD/static/counter.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target
4 |
5 | # These are backup files generated by rustfmt
6 | **/*.rs.bk
7 | persistence
8 |
9 | /.vscode
10 | Cargo.lock
11 |
--------------------------------------------------------------------------------
/examples/clock.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | LiveView Clock
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/todos.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | LiveView Todos
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/counter.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | LiveView Counter
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/rustfmt.toml:
--------------------------------------------------------------------------------
1 | # Stable
2 | newline_style = "Unix"
3 | use_field_init_shorthand = true
4 |
5 | # Unstable
6 | unstable_features = true
7 | version = "One"
8 | group_imports = "StdExternalCrate"
9 | imports_granularity = "Module"
10 | wrap_comments = true
11 |
--------------------------------------------------------------------------------
/static/clock.css:
--------------------------------------------------------------------------------
1 | body {
2 | text-align: center;
3 | font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif;
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | justify-content: center;
8 | min-height: 100vh;
9 | margin: 0;
10 | }
11 |
12 | h2 {
13 | margin-top: 0;
14 | font-size: 24px;
15 | }
16 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # LiveView Examples
2 |
3 | Examples can be run with `cargo run --example `.
4 |
5 | ### Clock
6 |
7 |
8 |
9 | ### Counter
10 |
11 |
12 |
13 | ### Todos
14 |
15 |
16 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "private": true,
4 | "version": "0.0.0",
5 | "scripts": {
6 | "build": "rollup -c"
7 | },
8 | "devDependencies": {
9 | "@rollup/plugin-commonjs": "^22.0.2",
10 | "@rollup/plugin-node-resolve": "^14.0.1",
11 | "@rollup/plugin-terser": "^0.1.0",
12 | "rollup": "^2.79.0",
13 | "rollup-plugin-inject-process-env": "^1.3.1"
14 | },
15 | "dependencies": {
16 | "phoenix": "^1.6.11",
17 | "phoenix_live_view": "^0.17.11",
18 | "topbar": "^1.0.1"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/static/counter.css:
--------------------------------------------------------------------------------
1 | body {
2 | font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif;
3 | }
4 |
5 | body > div {
6 | text-align: center;
7 | padding: 64px 0;
8 | }
9 |
10 | button {
11 | cursor: pointer;
12 | background: #1890ff;
13 | border: 1px solid #1890ff;
14 | color: #fff;
15 | border-radius: 2px;
16 | margin: 8px;
17 | font-size: 18px;
18 | padding: 8px 16px;
19 | transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
20 | }
21 |
22 | button:hover {
23 | background-color: #40a9ff;
24 | border-color: #40a9ff;
25 | }
26 |
27 | button:active {
28 | background-color: #096dd9;
29 | border-color: #096dd9;
30 | }
31 |
32 | p {
33 | font-size: 22px;
34 | }
35 |
--------------------------------------------------------------------------------
/web/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from "@rollup/plugin-node-resolve";
2 | import commonjs from "@rollup/plugin-commonjs";
3 | import terser from "@rollup/plugin-terser";
4 | import injectProcessEnv from "rollup-plugin-inject-process-env";
5 |
6 | function createExport(fileName, nodeEnv, minify) {
7 | return {
8 | input: "main.js",
9 | output: {
10 | file: `../dist/${fileName}`,
11 | format: "umd",
12 | },
13 | plugins: [
14 | resolve(), // so Rollup can find `ms`
15 | commonjs(), // so Rollup can convert `ms` to an ES module
16 | injectProcessEnv({
17 | NODE_ENV: nodeEnv,
18 | }),
19 | ...(minify ? [terser()] : []),
20 | ],
21 | };
22 | }
23 |
24 | export default [
25 | // debug
26 | createExport("liveview-debug.js", "development", false),
27 | // release
28 | createExport("liveview-release.js", "production", true),
29 | ];
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Lunatic
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/src/csrf.rs:
--------------------------------------------------------------------------------
1 | use base64::{engine::general_purpose, Engine};
2 | use rand::{thread_rng, Rng};
3 |
4 | #[derive(Clone, Debug, Default, PartialEq, Eq)]
5 | pub struct CsrfToken {
6 | pub masked: String,
7 | pub unmasked: String,
8 | }
9 |
10 | impl CsrfToken {
11 | /// Generates a crypto secure random key url-safe base64 encoded.
12 | pub fn generate() -> Self {
13 | let unmasked = generate_token();
14 | let masked = mask(&unmasked);
15 |
16 | CsrfToken { masked, unmasked }
17 | }
18 | }
19 |
20 | /// Generates a crypto secure random key url-safe base64 encoded.
21 | fn generate_token() -> String {
22 | let mut rng = thread_rng();
23 | let key: [u8; 18] = rng.gen();
24 | general_purpose::URL_SAFE.encode(key)
25 | }
26 |
27 | /// Masks a token by xor'ing with another generated token.
28 | fn mask(token: &str) -> String {
29 | let mask = generate_token();
30 | let xor: Vec<_> = token
31 | .as_bytes()
32 | .iter()
33 | .zip(mask.as_bytes().iter())
34 | .map(|(x1, x2)| x1 & x2)
35 | .collect();
36 | let mut masked = general_purpose::URL_SAFE.encode(xor);
37 | masked.push_str(&mask);
38 | masked
39 | }
40 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "submillisecond-live-view"
3 | version = "0.4.1"
4 | edition = "2021"
5 | license = "MIT/Apache-2.0"
6 | description = "A LiveView implementation for the submillisecond web framework."
7 | repository = "https://github.com/lunatic-solutions/submillisecond-live-view"
8 |
9 | [dependencies]
10 | base64 = "0.21"
11 | const-random = "0.1"
12 | enumflags2 = "0.7"
13 | hmac = { version = "0.12.1", features = ["std"] }
14 | itertools = "0.10"
15 | jwt = "0.16.0"
16 | lunatic = { version = "0.13", features = ["json_serializer"] }
17 | lunatic-log = "0.4"
18 | maud-live-view = "0.24.3"
19 | nipper = "0.1"
20 | pretty_assertions = "1.3"
21 | rand = "0.8"
22 | serde = { version = "1.0", features = ["derive"] }
23 | serde_json = "1.0"
24 | serde_qs = "0.12"
25 | sha2 = "0.10.6"
26 | slotmap = "1.0"
27 | submillisecond = { version = "0.4.0", features = ["cookies", "websocket"] }
28 | thiserror = "1.0"
29 | tungstenite = "0.19"
30 |
31 | [dev-dependencies]
32 | chrono = { version = "0.4", features = ["serde"] }
33 | chrono-tz = { version = "0.8", features = ["serde"] }
34 | uuid = { version = "1.3", features = ["serde", "v4"] }
35 |
36 | [features]
37 | default = ["liveview_js"]
38 | liveview_js = []
39 |
40 | [package.metadata.docs.rs]
41 | targets = ["wasm32-wasi"]
42 |
--------------------------------------------------------------------------------
/web/main.js:
--------------------------------------------------------------------------------
1 | // Establish Phoenix Socket and LiveView configuration.
2 | import { Socket } from "phoenix";
3 | import { LiveSocket } from "phoenix_live_view";
4 | import topbar from "topbar";
5 |
6 | const csrfToken = document
7 | .querySelector("meta[name='csrf-token']")
8 | .getAttribute("content");
9 |
10 | class SocketWrapper {
11 | constructor(endPoint, opts) {
12 | const socket = new Socket(endPoint, opts);
13 | socket.endPoint = endPoint;
14 | return socket;
15 | }
16 | }
17 |
18 | const liveSocket = new LiveSocket("/", SocketWrapper, {
19 | params: { _csrf_token: csrfToken },
20 | metadata: {
21 | click: (e, t) => ({
22 | detail: e.detail,
23 | }),
24 | },
25 | });
26 |
27 | // Show progress bar on live navigation and form submits
28 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" });
29 | window.addEventListener("phx:page-loading-start", (info) => topbar.show());
30 | window.addEventListener("phx:page-loading-stop", (info) => topbar.hide());
31 |
32 | // connect if there are any LiveViews on the page
33 | liveSocket.connect();
34 |
35 | // expose liveSocket on window for web console debug logs and latency simulation:
36 | if (process.env.NODE_ENV !== "production") {
37 | liveSocket.enableDebug();
38 | }
39 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
40 | // >> liveSocket.disableLatencySim()
41 |
42 | window.liveSocket = liveSocket;
43 |
--------------------------------------------------------------------------------
/examples/counter.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use submillisecond::{router, static_router, Application};
3 | use submillisecond_live_view::prelude::*;
4 |
5 | fn main() -> std::io::Result<()> {
6 | Application::new(router! {
7 | GET "/" => Counter::handler("examples/counter.html", "#app")
8 | "/static" => static_router!("./static")
9 | })
10 | .serve("127.0.0.1:3000")
11 | }
12 |
13 | #[derive(Clone, Serialize, Deserialize)]
14 | struct Counter {
15 | count: i32,
16 | }
17 |
18 | impl LiveView for Counter {
19 | type Events = (Increment, Decrement);
20 |
21 | fn mount(_uri: Uri, _socket: Option) -> Self {
22 | Counter { count: 0 }
23 | }
24 |
25 | fn render(&self) -> Rendered {
26 | html! {
27 | button @click=(Increment) { "Increment" }
28 | button @click=(Decrement) { "Decrement" }
29 | p { "Count is " (self.count) }
30 | @if self.count >= 5 {
31 | p { "Count is high!" }
32 | }
33 | }
34 | }
35 | }
36 |
37 | #[derive(Serialize, Deserialize)]
38 | struct Increment {}
39 |
40 | impl LiveViewEvent for Counter {
41 | fn handle(state: &mut Self, _event: Increment) {
42 | state.count += 1;
43 | }
44 | }
45 |
46 | #[derive(Serialize, Deserialize)]
47 | struct Decrement {}
48 |
49 | impl LiveViewEvent for Counter {
50 | fn handle(state: &mut Self, _event: Decrement) {
51 | state.count -= 1;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/rendered/dynamic.rs:
--------------------------------------------------------------------------------
1 | use std::{fmt, ops};
2 |
3 | use serde::{Deserialize, Serialize};
4 |
5 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
6 | pub(crate) enum Dynamics {
7 | Items(DynamicItems),
8 | List(DynamicList),
9 | }
10 |
11 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12 | pub(crate) struct DynamicItems(pub Vec>);
13 |
14 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
15 | pub(crate) struct DynamicList(pub Vec>>);
16 |
17 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18 | pub(crate) enum Dynamic {
19 | String(String),
20 | Nested(N),
21 | }
22 |
23 | impl ops::Deref for DynamicItems {
24 | type Target = Vec>;
25 |
26 | fn deref(&self) -> &Self::Target {
27 | &self.0
28 | }
29 | }
30 |
31 | impl ops::DerefMut for DynamicItems {
32 | fn deref_mut(&mut self) -> &mut Self::Target {
33 | &mut self.0
34 | }
35 | }
36 |
37 | impl ops::Deref for DynamicList {
38 | type Target = Vec>>;
39 |
40 | fn deref(&self) -> &Self::Target {
41 | &self.0
42 | }
43 | }
44 |
45 | impl ops::DerefMut for DynamicList {
46 | fn deref_mut(&mut self) -> &mut Self::Target {
47 | &mut self.0
48 | }
49 | }
50 |
51 | impl fmt::Display for Dynamic
52 | where
53 | N: fmt::Display,
54 | {
55 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 | match self {
57 | Dynamic::String(s) => {
58 | write!(f, "{s}")
59 | }
60 | Dynamic::Nested(n) => {
61 | write!(f, "{n}")
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/manager.rs:
--------------------------------------------------------------------------------
1 | use std::fmt;
2 |
3 | use serde::{Deserialize, Serialize};
4 | use serde_json::Value;
5 | use submillisecond::response::Response;
6 | use submillisecond::RequestContext;
7 |
8 | use crate::socket::{Event, JoinEvent, Socket};
9 | use crate::LiveView;
10 |
11 | /// Handles requests and events.
12 | pub(crate) trait LiveViewManager
13 | where
14 | Self: Sized,
15 | T: LiveView,
16 | {
17 | type State: Serialize + for<'de> Deserialize<'de>;
18 | // type Reply: Serialize;
19 | type Error: fmt::Display;
20 |
21 | /// Handle an initial stateless request.
22 | fn handle_request(&self, req: RequestContext) -> Response;
23 |
24 | /// Handle a join event returning state and a reply.
25 | fn handle_join(
26 | &self,
27 | socket: Socket,
28 | event: JoinEvent,
29 | ) -> LiveViewManagerResult, Self::Error>;
30 |
31 | /// Handle an event.
32 | fn handle_event(
33 | &self,
34 | event: Event,
35 | state: &mut Self::State,
36 | live_view: &T,
37 | ) -> LiveViewManagerResult