├── .cargo └── config.toml ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Cargo.toml ├── LICENSE_APACHE ├── LICENSE_MIT ├── README.md ├── autobahn ├── fuzzingclient.json └── server-results.json ├── bors.toml ├── examples ├── chat │ ├── Cargo.toml │ └── src │ │ └── main.rs └── list │ ├── Cargo.toml │ └── src │ └── main.rs ├── puck ├── .gitignore ├── Cargo.toml └── src │ ├── body │ ├── mime.rs │ └── mod.rs │ ├── core │ ├── mod.rs │ └── router │ │ ├── match_url.rs │ │ └── mod.rs │ ├── lib.rs │ ├── regressions │ └── mod.rs │ ├── request │ ├── builder.rs │ └── mod.rs │ ├── response │ ├── builder.rs │ ├── encoder.rs │ └── mod.rs │ └── ws │ ├── frame.rs │ ├── message.rs │ ├── mod.rs │ ├── send.rs │ ├── upgrade.rs │ └── websocket.rs ├── puck_liveview ├── Cargo.toml ├── client │ ├── index.html │ └── index.js ├── src │ ├── client │ │ ├── mod.rs │ │ └── send_changeset.rs │ ├── component │ │ └── mod.rs │ ├── dom │ │ ├── element │ │ │ ├── diff │ │ │ │ ├── changeset │ │ │ │ │ ├── apply.rs │ │ │ │ │ ├── instruction_serializer.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── mod.rs │ │ │ │ └── test_diffing.rs │ │ │ ├── mod.rs │ │ │ ├── orchestrator.rs │ │ │ └── render.rs │ │ ├── event.rs │ │ ├── listener.rs │ │ └── mod.rs │ ├── html │ │ ├── bigger_tree │ │ ├── id.rs │ │ ├── id_not_starting_from_zero │ │ ├── mod.rs │ │ ├── snapshots │ │ │ ├── puck_liveview__html__html_conversion.snap │ │ │ ├── puck_liveview__html__html_conversion_medium.snap │ │ │ ├── puck_liveview__html__html_conversion_offset_starting_id.snap │ │ │ └── puck_liveview__html__html_conversion_simple.snap │ │ └── tree │ ├── init.rs │ ├── lib.rs │ └── regressions │ │ └── mod.rs └── tests │ └── diffing.rs └── scripts └── server_test.sh /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-wasi" 3 | 4 | [target.wasm32-wasi] 5 | runner = "lunatic" 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - staging 7 | - trying 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions-rs/toolchain@v1 16 | with: 17 | toolchain: stable 18 | target: wasm32-wasi 19 | override: true 20 | components: rustfmt, clippy 21 | - uses: actions/cache@v2 22 | with: 23 | path: | 24 | ~/.cargo/registry 25 | ~/.cargo/git 26 | target 27 | key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml') }} 28 | restore-keys: | 29 | cargo-${{ runner.os }}- 30 | - name: Check formatting 31 | run: cargo fmt -- --check 32 | - name: Lint 33 | run: cargo clippy -- -D warnings 34 | 35 | main-tests: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Install Lunatic 40 | run: curl https://i.jpillora.com/lunatic-solutions/lunatic@v0.9.0! | sudo bash 41 | - uses: actions-rs/toolchain@v1 42 | with: 43 | toolchain: stable 44 | override: true 45 | target: wasm32-wasi 46 | - uses: Swatinem/rust-cache@v1 47 | - name: Run tests 48 | run: cargo test 49 | 50 | # Anyone who can fix this gets a metaphorical gold star. 51 | # 52 | # autobahn-tests: 53 | # runs-on: ubuntu-latest 54 | # steps: 55 | # - uses: actions/checkout@v2 56 | # - name: Install Lunatic 57 | # run: curl https://i.jpillora.com/lunatic-solutions/lunatic@v0.3.1! | sudo bash 58 | # - uses: actions-rs/toolchain@v1 59 | # with: 60 | # toolchain: stable 61 | # override: true 62 | # target: wasm32-wasi 63 | # - uses: actions/cache@v2 64 | # with: 65 | # path: | 66 | # ~/.cargo/registry 67 | # ~/.cargo/git 68 | # target 69 | # key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.toml') }} 70 | # restore-keys: | 71 | # cargo-${{ runner.os }}- 72 | # - run: ./scripts/server_test.sh 73 | 74 | bors-report: 75 | runs-on: ubuntu-latest 76 | 77 | needs: 78 | - main-tests 79 | - lint 80 | 81 | steps: 82 | - name: Check 83 | run: | 84 | [ ${{ needs.lint.result }} == success ] && 85 | [ ${{ needs.main-tests.result }} == success ] || exit 1 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors 2 | .idea 3 | .vscode 4 | # rust 5 | target/ 6 | Cargo.lock 7 | # autobahn test suite 8 | autobahn/server 9 | # fable 10 | .fable 11 | .package-lock.json 12 | node_modules 13 | # fuzzing 14 | fuzz 15 | 16 | .fake 17 | .ionide 18 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | `teymour dot aldridge at icloud dot com`. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "puck", 5 | "puck_liveview", 6 | "examples/list", 7 | "examples/chat" 8 | ] 9 | -------------------------------------------------------------------------------- /LICENSE_APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2019 Yoshua Wuyts 179 | Copyright 2016-2018 Michael Tilli (Pyfisch) & `httpdate` contributors 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /LICENSE_MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Namespace and contributors 4 | Copyright (c) 2019 Yoshua Wuyts 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Puck 2 | 3 | Software can sometimes feel a bit like: 4 | 5 | > I'll follow you. I'll lead you about a round, 6 | > Through a bog, through bush, through brake, through brier. 7 | > Sometime a horse I'll be, sometime a hound, 8 | > A hog, a headless bear, sometime a fire, 9 | > And neigh, and bark, and grunt, and roar, and burn, 10 | > Like horse, hound, hog, bear, fire, at every turn. (III.i.) 11 | 12 | - Puck in Act III of Shakespeare's "A Midsummer Night's Dream" 13 | 14 | Let's try to tame the complexity! 15 | 16 | ## Start here 17 | 18 | Puck is a (very experimental) HTTP library for the 19 | [Lunatic Virtual Machine](https://lunatic.solutions). 20 | 21 | Have a look at `examples/list` for more details :) 22 | 23 | ## Goals 24 | 25 | - Fast compile times (i.e. not longer than >10s for an incremental build) 26 | - Correct 27 | - Secure 28 | 29 | ## Non-goals 30 | 31 | - Be the fastest web framework 32 | 33 | ## Licensing 34 | 35 | Puck is provided under the terms of either the MIT license or the Apache 2.0 license – at your 36 | option. 37 | 38 | By submitting code to Puck (e.g. by opening a pull request), you agree to license your contribution 39 | under these terms. 40 | -------------------------------------------------------------------------------- /autobahn/fuzzingclient.json: -------------------------------------------------------------------------------- 1 | { 2 | "outdir": "./autobahn/server", 3 | "servers": [ 4 | { 5 | "agent": "Puck", 6 | "url": "ws://127.0.0.1:5051/ws" 7 | } 8 | ], 9 | "cases": [ 10 | "*" 11 | ], 12 | "exclude-cases": [ 13 | "9.*", 14 | "12.*", 15 | "13.*" 16 | ], 17 | "exclude-agent-cases": {} 18 | } -------------------------------------------------------------------------------- /bors.toml: -------------------------------------------------------------------------------- 1 | status = [ 2 | "bors-report" 3 | ] 4 | -------------------------------------------------------------------------------- /examples/chat/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chat" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | puck = { path = "../../puck" } 10 | puck_liveview = { path = "../../puck_liveview" } 11 | serde = { version = "1.0.138", features = ["derive"] } 12 | -------------------------------------------------------------------------------- /examples/list/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "list" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | puck = { path = "../../puck" } 10 | lunatic = "0.9.1" 11 | serde = { version = "1.0.138", features = ["derive"] } 12 | malvolio = "0.3.1" 13 | -------------------------------------------------------------------------------- /examples/list/src/main.rs: -------------------------------------------------------------------------------- 1 | use lunatic::{ 2 | process::{AbstractProcess, ProcessRef, ProcessRequest, Request, StartProcess}, 3 | Mailbox, 4 | }; 5 | use malvolio::prelude::*; 6 | use puck::{ 7 | body::Body, 8 | core::{ 9 | router::{ 10 | match_url::{self, Match}, 11 | Route, Router, 12 | }, 13 | Core, 14 | }, 15 | request::Method, 16 | Response, 17 | }; 18 | 19 | #[lunatic::main] 20 | fn main(_: Mailbox<()>) { 21 | let proc = List::start(vec![], None); 22 | 23 | let router = Router::>::new() 24 | .route(Route::new( 25 | |request| { 26 | request.method() == &Method::Get 27 | && Match::new() 28 | .at(match_url::path("submit")) 29 | .does_match(request.url()) 30 | }, 31 | |mut _request, stream, _state| { 32 | stream 33 | .respond( 34 | Response::build() 35 | .headers(vec![("Content-Type".to_string(), "text/html".to_string())]) 36 | .body(Body::from_string( 37 | html().head(head().child(title("Submit a message"))).body( 38 | body().child( 39 | form() 40 | .attribute(malvolio::prelude::Method::Post) 41 | .child(input().attribute(Name::new("message"))) 42 | .child(input().attribute(Type::Submit)), 43 | ), 44 | ), 45 | )) 46 | .build(), 47 | ) 48 | .unwrap() 49 | }, 50 | )) 51 | .route(Route::new( 52 | |request| { 53 | request.method() == &Method::Post 54 | && Match::new() 55 | .at(match_url::path("submit")) 56 | .does_match(request.url()) 57 | }, 58 | |mut request, stream, state| { 59 | let res = request.take_body().into_string().unwrap(); 60 | 61 | if res.starts_with("message=") { 62 | // beware of how utf-8 works if you copy this 63 | let seg = res.split_at("message=".len()).1; 64 | 65 | match state.request(Msg::Add(seg.to_string())) { 66 | Reply::Items(_) => unreachable!(), 67 | Reply::Added => stream 68 | .respond( 69 | Response::build() 70 | .headers(vec![( 71 | "Content-Type".to_string(), 72 | "text/html".to_string(), 73 | )]) 74 | .body(Body::from_string( 75 | html() 76 | .head(head().child(title("Submit a message"))) 77 | .body(body().child(h1("Added that item"))), 78 | )) 79 | .build(), 80 | ) 81 | .unwrap(), 82 | } 83 | } else { 84 | stream.respond(puck::err_400()).unwrap() 85 | } 86 | }, 87 | )) 88 | .route(Route::new( 89 | |request| { 90 | Match::new() 91 | .at(match_url::path("read")) 92 | .at(match_url::any_integer()) 93 | .does_match(request.url()) 94 | }, 95 | |request, stream, state| { 96 | let segment = request.url().path().split_at("/read/".len()).1; 97 | let n = segment.parse::().unwrap(); 98 | let res = state.request(Msg::LastN(n)); 99 | let items = match res { 100 | Reply::Items(items) => items, 101 | Reply::Added => unreachable!(), 102 | }; 103 | stream 104 | .respond( 105 | puck::Response::build() 106 | .headers(vec![("Content-Type".to_string(), "text/html".to_string())]) 107 | .body(Body::from_string( 108 | html().head(head().child(title("Message list"))).body( 109 | body().child(h1("Message list")).map(|body| { 110 | if items.is_empty() { 111 | body.child(p().text("There are no messages yet.")) 112 | } else { 113 | body.children( 114 | items.into_iter().map(|item| { 115 | p().text(format!("Item: {}", item)) 116 | }), 117 | ) 118 | } 119 | }), 120 | ), 121 | )) 122 | .build(), 123 | ) 124 | .unwrap() 125 | }, 126 | )) 127 | .route(Route::new( 128 | |_request| true, 129 | |_request, stream, _state| stream.respond(puck::err_404()).unwrap(), 130 | )); 131 | 132 | Core::bind("localhost:8080", proc) 133 | .expect("failed to launch") 134 | .serve_router(router); 135 | } 136 | 137 | #[derive(serde::Serialize, serde::Deserialize)] 138 | enum Msg { 139 | Add(String), 140 | AllItems, 141 | LastN(usize), 142 | } 143 | 144 | struct List { 145 | items: Vec, 146 | } 147 | 148 | impl AbstractProcess for List { 149 | type Arg = Vec; 150 | 151 | type State = Self; 152 | 153 | fn init(_: lunatic::process::ProcessRef, arg: Self::Arg) -> Self::State { 154 | Self { items: arg } 155 | } 156 | } 157 | 158 | impl ProcessRequest for List { 159 | type Response = Reply; 160 | 161 | fn handle(state: &mut Self::State, req: Msg) -> Self::Response { 162 | match req { 163 | Msg::Add(string) => { 164 | state.items.push(string); 165 | Reply::Added 166 | } 167 | Msg::AllItems => Reply::Items(state.items.clone()), 168 | Msg::LastN(n) => { 169 | if state.items.len() < n { 170 | Reply::Items(state.items.clone()) 171 | } else { 172 | Reply::Items(state.items.get(0..).unwrap().to_vec()) 173 | } 174 | } 175 | } 176 | } 177 | } 178 | 179 | #[derive(serde::Serialize, serde::Deserialize)] 180 | enum Reply { 181 | Items(Vec), 182 | Added, 183 | } 184 | -------------------------------------------------------------------------------- /puck/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /puck/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "puck" 3 | version = "0.1.0" 4 | authors = ["teymour-aldridge "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | anyhow = "1.0.58" 11 | httparse = "1.7.1" 12 | thiserror = "1.0.31" 13 | url = "2.2.2" 14 | sha-1 = "0.10.0" 15 | base64 = "0.13.0" 16 | byteorder = "1.4.3" 17 | log = "0.4.17" 18 | serde = { version = "1.0.138", features = ["derive"] } 19 | lunatic = "0.9.1" 20 | -------------------------------------------------------------------------------- /puck/src/body/mime.rs: -------------------------------------------------------------------------------- 1 | //! HTTP MIME types. 2 | 3 | use std::{borrow::Cow, fmt::Display}; 4 | 5 | /* This code comes from https://github.com/http-rs/http-types/blob/main/src/mime/parse.rs */ 6 | 7 | #[derive(Debug, Clone)] 8 | /// The MIME type of a request. 9 | /// 10 | /// See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) for an 11 | /// introduction to how MIME-types work. 12 | #[allow(dead_code)] 13 | pub struct Mime { 14 | pub(crate) essence: Cow<'static, str>, 15 | pub(crate) basetype: Cow<'static, str>, 16 | pub(crate) subtype: Cow<'static, str>, 17 | pub(crate) is_utf8: bool, 18 | pub(crate) params: Vec<(ParamName, ParamValue)>, 19 | } 20 | 21 | impl Display for Mime { 22 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 | format(self, f) 24 | } 25 | } 26 | 27 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 28 | pub struct ParamName(Cow<'static, str>); 29 | 30 | impl Display for ParamName { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | Display::fmt(&self.0, f) 33 | } 34 | } 35 | 36 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 37 | pub struct ParamValue(Cow<'static, str>); 38 | 39 | impl Display for ParamValue { 40 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 41 | Display::fmt(&self.0, f) 42 | } 43 | } 44 | 45 | pub const HTML: Mime = Mime { 46 | essence: Cow::Borrowed("text/html"), 47 | basetype: Cow::Borrowed("text"), 48 | subtype: Cow::Borrowed("html"), 49 | is_utf8: true, 50 | params: vec![], 51 | }; 52 | 53 | pub const PLAIN: Mime = Mime { 54 | essence: Cow::Borrowed("text/plain"), 55 | basetype: Cow::Borrowed("text"), 56 | subtype: Cow::Borrowed("plain"), 57 | is_utf8: true, 58 | params: vec![], 59 | }; 60 | 61 | pub const BYTE_STREAM: Mime = Mime { 62 | essence: Cow::Borrowed("application/octet-stream"), 63 | basetype: Cow::Borrowed("application"), 64 | subtype: Cow::Borrowed("octet-stream"), 65 | is_utf8: false, 66 | params: vec![], 67 | }; 68 | 69 | /// Implementation of the 70 | /// [WHATWG MIME serialization algorithm](https://mimesniff.spec.whatwg.org/#serializing-a-mime-type) 71 | pub(crate) fn format(mime_type: &Mime, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 72 | write!(f, "{}", &mime_type.essence)?; 73 | if mime_type.is_utf8 { 74 | write!(f, ";charset=utf-8")?; 75 | } 76 | for (name, value) in mime_type.params.iter() { 77 | if value.0.chars().all(is_http_token_code_point) && !value.0.is_empty() { 78 | write!(f, ";{}={}", name, value)?; 79 | } else { 80 | let value = value 81 | .0 82 | .chars() 83 | .flat_map(|c| match c { 84 | '"' | '\\' => EscapeMimeValue::backslash(c), 85 | c => EscapeMimeValue::char(c), 86 | }) 87 | .collect::(); 88 | write!(f, ";{}=\"{}\"", name, value)?; 89 | } 90 | } 91 | Ok(()) 92 | } 93 | 94 | struct EscapeMimeValue { 95 | state: EscapeMimeValueState, 96 | } 97 | 98 | impl EscapeMimeValue { 99 | fn backslash(c: char) -> Self { 100 | EscapeMimeValue { 101 | state: EscapeMimeValueState::Backslash(c), 102 | } 103 | } 104 | 105 | fn char(c: char) -> Self { 106 | EscapeMimeValue { 107 | state: EscapeMimeValueState::Char(c), 108 | } 109 | } 110 | } 111 | 112 | #[derive(Clone, Debug)] 113 | enum EscapeMimeValueState { 114 | Done, 115 | Char(char), 116 | Backslash(char), 117 | } 118 | 119 | impl Iterator for EscapeMimeValue { 120 | type Item = char; 121 | 122 | fn next(&mut self) -> Option { 123 | match self.state { 124 | EscapeMimeValueState::Done => None, 125 | EscapeMimeValueState::Char(c) => { 126 | self.state = EscapeMimeValueState::Done; 127 | Some(c) 128 | } 129 | EscapeMimeValueState::Backslash(c) => { 130 | self.state = EscapeMimeValueState::Char(c); 131 | Some('\\') 132 | } 133 | } 134 | } 135 | 136 | fn size_hint(&self) -> (usize, Option) { 137 | match self.state { 138 | EscapeMimeValueState::Done => (0, Some(0)), 139 | EscapeMimeValueState::Char(_) => (1, Some(1)), 140 | EscapeMimeValueState::Backslash(_) => (2, Some(2)), 141 | } 142 | } 143 | } 144 | 145 | /// Validates [HTTP token code points](https://mimesniff.spec.whatwg.org/#http-token-code-point) 146 | fn is_http_token_code_point(c: char) -> bool { 147 | matches!(c, 148 | '!' 149 | | '#' 150 | | '$' 151 | | '%' 152 | | '&' 153 | | '\'' 154 | | '*' 155 | | '+' 156 | | '-' 157 | | '.' 158 | | '^' 159 | | '_' 160 | | '`' 161 | | '|' 162 | | '~' 163 | | 'a'..='z' 164 | | 'A'..='Z' 165 | | '0'..='9') 166 | } 167 | 168 | /* End "borrowed" code section. */ 169 | -------------------------------------------------------------------------------- /puck/src/body/mod.rs: -------------------------------------------------------------------------------- 1 | //! HTTP bodies. 2 | 3 | use std::fmt; 4 | use std::io::{BufRead, Cursor, Read}; 5 | 6 | use self::mime::{Mime, BYTE_STREAM}; 7 | 8 | // for now, todo: add documentation 9 | #[allow(missing_docs)] 10 | pub mod mime; 11 | 12 | #[cfg_attr(feature = "fuzzing", derive(DefaultMutator, ToJson, FromJson))] 13 | /// An HTTP `Body`. This struct contains an IO source, from which the `Body`'s contents can be read, 14 | /// as well as the MIME type of the contents of the `Body`. 15 | pub struct Body { 16 | reader: Box, 17 | pub(crate) mime: Mime, 18 | pub(crate) length: Option, 19 | bytes_read: usize, 20 | } 21 | 22 | impl fmt::Debug for Body { 23 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 24 | f.debug_struct("Body") 25 | .field("mime", &self.mime) 26 | .field("length", &self.length) 27 | .field("bytes_read", &self.bytes_read) 28 | .finish_non_exhaustive() 29 | } 30 | } 31 | 32 | impl Body { 33 | /// Create an empty `Body`. 34 | pub fn empty() -> Self { 35 | Self { 36 | reader: Box::new(Cursor::new(b"")), 37 | mime: BYTE_STREAM, 38 | length: Some(0), 39 | bytes_read: 0, 40 | } 41 | } 42 | 43 | /// Construct a new `Body` from the provided reader (which should implement `BufRead`). Note 44 | /// that if you can, you should ideally supply `content_length`. 45 | pub fn from_reader(reader: impl BufRead + 'static, content_length: Option) -> Self { 46 | Self { 47 | reader: Box::new(reader), 48 | mime: BYTE_STREAM, 49 | length: content_length, 50 | bytes_read: 0, 51 | } 52 | } 53 | 54 | /// Create a new `Body` from the provided string (this method accepts anything implementing 55 | /// `Display`.) 56 | /// 57 | /// This method is equivalent to `From for Body` or `From<&str> for Body`. 58 | pub fn from_string(string: impl ToString) -> Self { 59 | let string = string.to_string(); 60 | let length = Some(string.len()); 61 | Self { 62 | reader: Box::new(Cursor::new(string)), 63 | mime: BYTE_STREAM, 64 | length, 65 | bytes_read: 0, 66 | } 67 | } 68 | 69 | /// Reads to completion from the underlying IO source, and returns the result as bytes 70 | /// (`Vec`). 71 | pub fn into_bytes(mut self) -> std::io::Result> { 72 | let mut buf = Vec::with_capacity(1024); 73 | self.read_to_end(&mut buf)?; 74 | Ok(buf) 75 | } 76 | 77 | /// Reads to completion from the underlying IO source, and returns the result as a 78 | /// `String`. 79 | pub fn into_string(mut self) -> std::io::Result { 80 | let mut result = String::with_capacity(self.length.unwrap_or(0)); 81 | self.read_to_string(&mut result)?; 82 | Ok(result) 83 | } 84 | } 85 | 86 | impl From for Body { 87 | fn from(string: String) -> Self { 88 | Body::from_string(string) 89 | } 90 | } 91 | 92 | impl From<&str> for Body { 93 | fn from(string: &str) -> Self { 94 | Body::from_string(string) 95 | } 96 | } 97 | 98 | impl Read for Body { 99 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result { 100 | let buf = match self.length { 101 | None => buf, 102 | Some(length) if length == self.bytes_read => return Ok(0), 103 | Some(length) => { 104 | let max_length = (length - self.bytes_read).min(buf.len()); 105 | &mut buf[0..max_length] 106 | } 107 | }; 108 | let bytes = self.reader.read(buf)?; 109 | self.bytes_read += bytes; 110 | Ok(bytes) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /puck/src/core/mod.rs: -------------------------------------------------------------------------------- 1 | //! 2 | 3 | use std::{io, mem}; 4 | 5 | use lunatic::{ 6 | net::{TcpListener, TcpStream, ToSocketAddrs}, 7 | Mailbox, Process, 8 | }; 9 | use serde::{de::DeserializeOwned, Serialize}; 10 | 11 | use crate::{ 12 | response::encoder::Encoder, 13 | ws::{self, websocket::WebSocket}, 14 | Request, Response, 15 | }; 16 | 17 | use self::router::Router; 18 | 19 | pub mod router; 20 | 21 | /// 22 | #[derive(Debug)] 23 | pub struct Core { 24 | state: STATE, 25 | listener: TcpListener, 26 | } 27 | 28 | impl Core 29 | where 30 | STATE: Clone + Serialize + DeserializeOwned, 31 | { 32 | /// Bind 33 | pub fn bind(addr: impl ToSocketAddrs, state: STATE) -> Result { 34 | Ok(Self { 35 | state, 36 | listener: TcpListener::bind(addr)?, 37 | }) 38 | } 39 | 40 | /// Serves the current router, forever, on the bound address. 41 | pub fn serve_router(self, router: Router) { 42 | let ints = router.as_ints(); 43 | 44 | loop { 45 | if let Ok((stream, _)) = self.listener.accept() { 46 | let _ = Process::spawn( 47 | (stream, ints.clone(), self.state.clone()), 48 | |(stream, ints, state), _: Mailbox<()>| { 49 | let router = Router::::from_ints(ints); 50 | 51 | let req = if let Some(req) = Request::parse(stream.clone()).unwrap() { 52 | req 53 | } else { 54 | let stream = Stream::new(stream, false); 55 | // can't do much if this fails 56 | // todo: log it somehow 57 | let _ = stream.respond(crate::err_400()); 58 | return; 59 | }; 60 | 61 | let stream = Stream::new(stream, false); 62 | 63 | router.respond(req, stream, state); 64 | }, 65 | ); 66 | } 67 | } 68 | } 69 | 70 | /// Apply the provided function to every request. 71 | /// 72 | /// This option gives you maximum flexibility. 73 | /// 74 | /// note: if you choose this option, then the router will not be automatically applied to each 75 | /// request. 76 | pub fn for_each(self, func: fn(Request, Stream, STATE) -> UsedStream) { 77 | loop { 78 | if let Ok((stream, _)) = self.listener.accept() { 79 | let pointer = func as *const () as usize; 80 | 81 | let _ = Process::spawn( 82 | (pointer, stream.clone(), self.state.clone()), 83 | |(pointer, stream, state), _: Mailbox<()>| { 84 | let reconstructed_func = pointer as *const (); 85 | let reconstructed_func = unsafe { 86 | mem::transmute::<*const (), fn(Request, Stream, STATE) -> UsedStream>( 87 | reconstructed_func, 88 | ) 89 | }; 90 | 91 | let req = Request::parse(stream.clone()); 92 | 93 | match req { 94 | Ok(Some(req)) => { 95 | let stream = Stream::new(stream, false); 96 | 97 | // todo: keep-alive 98 | let _recovered_stream = (reconstructed_func)(req, stream, state); 99 | } 100 | _ => { 101 | todo!() 102 | } 103 | } 104 | }, 105 | ); 106 | } 107 | } 108 | } 109 | } 110 | /// 111 | #[derive(Debug)] 112 | pub struct Stream { 113 | stream: TcpStream, 114 | /// Can this stream be kept alive once it is returned to the web server? 115 | /// 116 | /// If upgraded to a WebSocket connection, or the Content-Length is not 117 | /// specified by the client, then this is not possible. 118 | keep_alive: bool, 119 | } 120 | 121 | /// An error encountered when trying to upgrade a WebSocket connection. 122 | #[derive(Debug)] 123 | pub enum UpgradeError { 124 | /// Some other error not represented by another variant of this enum 125 | /// occured. 126 | __NonExhaustive, 127 | } 128 | 129 | impl Stream { 130 | // note: no keep_alive support for now! 131 | fn new(stream: TcpStream, keep_alive: bool) -> Stream { 132 | Self { stream, keep_alive } 133 | } 134 | 135 | /// Upgrade 136 | pub fn upgrade(mut self, req: &Request) -> Result { 137 | self.keep_alive = false; 138 | 139 | if !ws::should_upgrade(req) { 140 | return Err(self.respond(crate::err_400()).unwrap()); 141 | } 142 | 143 | if !ws::perform_upgrade(req, self.stream.clone()) { 144 | return Err(UsedStream::empty()); 145 | } 146 | 147 | Ok(WebSocket::new(self.stream)) 148 | } 149 | 150 | /// Send a response 151 | pub fn respond(self, response: Response) -> Result { 152 | let mut enc = Encoder::new(response); 153 | 154 | enc.write_tcp_stream(self.stream.clone())?; 155 | 156 | Ok(UsedStream { 157 | stream: Some(self.stream), 158 | keep_alive: self.keep_alive, 159 | }) 160 | } 161 | } 162 | 163 | #[derive(Debug)] 164 | #[allow(unused)] 165 | /// 166 | pub struct UsedStream { 167 | pub(crate) stream: Option, 168 | pub(crate) keep_alive: bool, 169 | } 170 | 171 | impl UsedStream { 172 | /// Returns an empty stream. 173 | // todo: remove this API asap 174 | pub fn empty() -> UsedStream { 175 | UsedStream { 176 | stream: None, 177 | keep_alive: false, 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /puck/src/core/router/match_url.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for matching urls. 2 | 3 | use url::Url; 4 | 5 | #[derive(Debug, Default)] 6 | /// Match against an URL. 7 | #[must_use] 8 | pub struct Match { 9 | segments: Vec, 10 | // todo: other things, e.g. query params 11 | } 12 | 13 | impl Match { 14 | /// Construct a new [Match] 15 | pub fn new() -> Match { 16 | Default::default() 17 | } 18 | 19 | /// Add a new segment to the matcher. 20 | pub fn at(mut self, seg: Segment) -> Match { 21 | self.segments.push(seg); 22 | self 23 | } 24 | 25 | /// Test if this matcher matches the url. 26 | pub fn does_match(&self, url: &Url) -> bool { 27 | let mut expected_iter = self.segments.iter(); 28 | let mut actual_iter = if let Some(segments) = url.path_segments() { 29 | segments 30 | } else { 31 | return false; 32 | }; 33 | 34 | loop { 35 | let expected = if let Some(expected) = expected_iter.next() { 36 | expected 37 | } else { 38 | return actual_iter.next().is_none(); 39 | }; 40 | 41 | let actual = if let Some(actual) = actual_iter.next() { 42 | actual 43 | } else { 44 | return false; 45 | }; 46 | 47 | match expected { 48 | Segment::Static(expected_path) => { 49 | if *expected_path != actual { 50 | return false; 51 | } 52 | } 53 | Segment::Param => {} 54 | Segment::IntParam => { 55 | if actual.parse::().is_err() { 56 | return false; 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | #[derive(Debug)] 65 | /// A unit of a path. 66 | pub enum Segment { 67 | /// Matches this string exactly 68 | Static(&'static str), 69 | /// Anything 70 | Param, 71 | /// Any integer 72 | IntParam, 73 | } 74 | 75 | /// Syntactic sugar to construct a [Segment]. 76 | pub fn path(path: &'static str) -> Segment { 77 | Segment::Static(path) 78 | } 79 | 80 | /// Syntactic sugar to construct a [Segment]. 81 | pub fn anything() -> Segment { 82 | Segment::Param 83 | } 84 | 85 | /// Syntactic sugar to construct a [Segment]. 86 | pub fn any_integer() -> Segment { 87 | Segment::IntParam 88 | } 89 | 90 | #[cfg(test)] 91 | mod test { 92 | use std::str::FromStr; 93 | 94 | use super::*; 95 | 96 | #[lunatic::test] 97 | fn test_simple() { 98 | let matcher = Match::new() 99 | .at(path("home")) 100 | .at(path("name")) 101 | .at(anything()) 102 | .at(path("page")) 103 | .at(any_integer()); 104 | 105 | assert!(matcher 106 | .does_match(&Url::from_str("https://example.com/home/name/someone/page/12").unwrap())); 107 | assert!(!matcher.does_match( 108 | &Url::from_str("https://example.com/home/name/someone/page/twelve").unwrap() 109 | )); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /puck/src/core/router/mod.rs: -------------------------------------------------------------------------------- 1 | //! A router. 2 | use std::{fmt, mem}; 3 | 4 | use lunatic::{net::TcpListener, Mailbox, Process}; 5 | use serde::{de::DeserializeOwned, Serialize}; 6 | 7 | use crate::Request; 8 | 9 | use super::{Stream, UsedStream}; 10 | 11 | pub mod match_url; 12 | 13 | #[allow(missing_docs)] 14 | #[derive(Copy, Clone)] 15 | #[must_use] 16 | pub struct Route { 17 | matcher: fn(&Request) -> bool, 18 | handler: fn(Request, Stream, STATE) -> UsedStream, 19 | } 20 | 21 | impl fmt::Debug for Route { 22 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 23 | f.debug_struct("Route").finish() 24 | } 25 | } 26 | 27 | impl Route { 28 | /// Constructs a new `Route`. 29 | /// 30 | /// A substantially nicer API will come. 31 | pub fn new( 32 | matcher: fn(&Request) -> bool, 33 | handler: fn(Request, Stream, STATE) -> UsedStream, 34 | ) -> Route { 35 | Route { matcher, handler } 36 | } 37 | } 38 | 39 | /// A [Router] provides an easy way to match different types of HTTP request and handle them 40 | /// differently. 41 | #[derive(Debug, Clone, Default)] 42 | #[must_use] 43 | pub struct Router { 44 | routes: Vec>, 45 | } 46 | 47 | impl Router { 48 | /// Constructs a new [Router]. 49 | pub fn new() -> Router { 50 | Router { routes: vec![] } 51 | } 52 | 53 | /// Add a route to the router. 54 | pub fn route(mut self, route: Route) -> Router { 55 | self.routes.push(route); 56 | self 57 | } 58 | 59 | /// Converts the router into a series of integers. 60 | pub(crate) fn as_ints(&self) -> Vec<(usize, usize)> { 61 | self.routes 62 | .iter() 63 | .map(|route| { 64 | ( 65 | route.matcher as *const () as usize, 66 | route.handler as *const () as usize, 67 | ) 68 | }) 69 | .collect() 70 | } 71 | 72 | /// Reconstructs the router from `Router::as_ints`. Panics if the data is not in a valid form. 73 | pub(crate) fn from_ints(ints: Vec<(usize, usize)>) -> Router { 74 | let routes = ints 75 | .iter() 76 | .map(|(matcher, handler)| Route { 77 | matcher: { 78 | unsafe { 79 | let pointer = *matcher as *const (); 80 | mem::transmute::<*const (), fn(&Request) -> bool>(pointer) 81 | } 82 | }, 83 | handler: { 84 | unsafe { 85 | let pointer = *handler as *const (); 86 | mem::transmute::<*const (), fn(Request, Stream, STATE) -> UsedStream>( 87 | pointer, 88 | ) 89 | } 90 | }, 91 | }) 92 | .collect::>(); 93 | Router { routes } 94 | } 95 | 96 | /// Runs the router forever on the provided port. 97 | pub fn run(self, listener: TcpListener, state: STATE) { 98 | loop { 99 | let stream = if let Ok((stream, _addr)) = listener.accept() { 100 | stream 101 | } else { 102 | continue; 103 | }; 104 | 105 | let _ = Process::spawn( 106 | (self.as_ints(), stream, state.clone()), 107 | |(ints, stream, state), _: Mailbox<()>| { 108 | if let Ok(Some(req)) = Request::parse(stream.clone()) { 109 | let router = Router::::from_ints(ints); 110 | router.respond(req, Stream::new(stream, false), state); 111 | } 112 | }, 113 | ); 114 | } 115 | } 116 | 117 | pub(crate) fn respond(&self, req: Request, stream: Stream, state: STATE) { 118 | for route in &self.routes { 119 | if (route.matcher)(&req) { 120 | (route.handler)(req, stream, state); 121 | return; 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /puck/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! An HTTP library for the Lunatic Virtual Machine. 2 | 3 | #![deny(missing_debug_implementations, unused_must_use, missing_docs)] 4 | 5 | use std::{collections::HashMap, io::Write}; 6 | 7 | #[cfg(test)] 8 | mod regressions; 9 | 10 | use body::{mime::HTML, Body}; 11 | 12 | pub use anyhow; 13 | pub use lunatic; 14 | pub use request::Request; 15 | pub use response::Response; 16 | 17 | use response::encoder::Encoder; 18 | 19 | pub mod body; 20 | pub mod core; 21 | pub mod request; 22 | pub mod response; 23 | pub mod ws; 24 | 25 | /// Return an error 404 not found response. 26 | pub fn err_404() -> Response { 27 | Response { 28 | headers: { 29 | let mut res = HashMap::new(); 30 | res.insert("Content-Type".to_string(), HTML.to_string()); 31 | res 32 | }, 33 | body: Body::from_string("

404: Not found

".to_string()), 34 | status: 404, 35 | reason: "not found".to_string(), 36 | } 37 | } 38 | 39 | /// Return a `400` error response. 40 | pub fn err_400() -> Response { 41 | Response { 42 | headers: { 43 | let mut res = HashMap::new(); 44 | res.insert("Content-Type".to_string(), HTML.to_string()); 45 | res 46 | }, 47 | body: Body::from_string("

400: bad request

".to_string()), 48 | status: 400, 49 | reason: "bad request".to_string(), 50 | } 51 | } 52 | 53 | /// Write the given response to a writable TCP stream. 54 | pub fn write_response(res: Response, stream: impl Write) { 55 | let mut encoder = Encoder::new(res); 56 | encoder.write_tcp_stream(stream).unwrap(); 57 | } 58 | -------------------------------------------------------------------------------- /puck/src/regressions/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io::Cursor; 2 | 3 | use crate::{body::Body, request::Method, Request}; 4 | 5 | fn execute_test(headers: Vec<(String, String)>, body: impl ToString) { 6 | let mut req = Request::build("http://example.com") 7 | .headers(headers.clone()) 8 | .method(Method::Get) 9 | .body(Body::from_string(body)) 10 | .build(); 11 | 12 | let mut vec = Vec::new(); 13 | 14 | req.write(&mut vec).expect("failed to write request"); 15 | 16 | let req = Request::parse(Cursor::new(vec)) 17 | .expect("failed to parse request") 18 | .expect("emtpy request"); 19 | 20 | for header in req.headers { 21 | assert!(headers.contains(&header)) 22 | } 23 | } 24 | 25 | #[lunatic::test] 26 | /// This regression test came from https://github.com/bailion/puck/runs/2756775397 27 | fn test_inverse_request_regression_2021_06_06_morning() { 28 | let headers = vec![ 29 | ("NW".to_string(), "gcfZ-spKEf-v-gh".to_string()), 30 | ("Host".to_string(), "example.com".to_string()), 31 | ]; 32 | execute_test(headers, "u𘐿\\K;ῗ𐰑𖭜R"); 33 | } 34 | 35 | #[lunatic::test] 36 | /// This regression test came from https://github.com/bailion/puck/runs/2758615118 37 | fn test_inverse_request_regression_2021_06_06_afternoon() { 38 | let headers = vec![ 39 | ("aA".to_string(), "aa".to_string()), 40 | ("Host".to_string(), "example.com".to_string()), 41 | ]; 42 | execute_test(headers, ""); 43 | } 44 | -------------------------------------------------------------------------------- /puck/src/request/builder.rs: -------------------------------------------------------------------------------- 1 | //! A `Request` builder. 2 | 3 | use std::{collections::HashMap, convert::TryFrom}; 4 | 5 | use url::Url; 6 | 7 | use crate::{body::Body, Request}; 8 | 9 | use super::Method; 10 | 11 | #[derive(Debug)] 12 | #[must_use] 13 | /// A struct used to build HTTP requests. 14 | pub struct RequestBuilder { 15 | pub(crate) headers: HashMap, 16 | pub(crate) method: Option, 17 | pub(crate) body: Option, 18 | pub(crate) url: Url, 19 | } 20 | 21 | impl RequestBuilder { 22 | /// Construct a new `Request` pointing to the provided URL. If the URL is invalid, this method 23 | /// will panic. 24 | pub fn new(url: impl AsRef) -> Self { 25 | Self::try_new(url).expect("`RequestBuilder` failed to parse the provided URL") 26 | } 27 | 28 | /// Construct a new `Request` using the provided URL, returning an error if the URL is not 29 | /// valid. 30 | pub fn try_new(url: impl AsRef) -> Result { 31 | Ok(Self { 32 | headers: HashMap::new(), 33 | method: None, 34 | body: None, 35 | url: TryFrom::try_from(url.as_ref())?, 36 | }) 37 | } 38 | 39 | /// Add a new HTTP header to this `Request`. 40 | pub fn header(mut self, key: impl Into, value: impl Into) -> Self { 41 | self.headers.insert(key.into(), value.into()); 42 | self 43 | } 44 | 45 | /// Add a series of new HTTP headers from the provided iterator to this request. This function 46 | /// accepts anything implementing `IntoIterator`. 47 | pub fn headers(mut self, new_headers: impl IntoIterator) -> Self { 48 | self.headers.extend(new_headers); 49 | self 50 | } 51 | 52 | /// Attach a `Body` to this `Request`. 53 | pub fn body(mut self, body: impl Into) -> Self { 54 | self.body = Some(body.into()); 55 | self 56 | } 57 | 58 | /// Attach a new method to this HTTP request. 59 | pub fn method(mut self, method: impl Into) -> Self { 60 | self.method = Some(method.into()); 61 | self 62 | } 63 | 64 | /// Build this `Request`, and panic if it is not possible to do so. 65 | pub fn build(self) -> Request { 66 | self.try_build() 67 | .expect("a request method was not provided to `RequestBuilder`.") 68 | } 69 | 70 | /// Try to build this request, returning an error if the operation fails. 71 | pub fn try_build(self) -> Result { 72 | Ok(Request { 73 | headers: self.headers, 74 | method: self 75 | .method 76 | .map(Ok) 77 | .unwrap_or(Err(TryBuildError::MethodNotProvided))?, 78 | body: self.body.unwrap_or_else(Body::empty), 79 | url: self.url, 80 | }) 81 | } 82 | } 83 | 84 | #[derive(thiserror::Error, Debug, Clone)] 85 | /// An error encountered when attempting to construct a `Request`. 86 | pub enum TryBuildError { 87 | #[error("method not provided")] 88 | /// The request method was not provided. 89 | MethodNotProvided, 90 | } 91 | -------------------------------------------------------------------------------- /puck/src/request/mod.rs: -------------------------------------------------------------------------------- 1 | //! HTTP requests. 2 | 3 | use std::{ 4 | collections::HashMap, 5 | io::{self, BufRead, BufReader, Read, Write}, 6 | str::Utf8Error, 7 | }; 8 | 9 | use url::{ParseError, Url}; 10 | 11 | use crate::body::Body; 12 | 13 | pub mod builder; 14 | 15 | /// The maximum number of headers which Puck will parse. 16 | pub const MAX_HEADERS: usize = 20; 17 | 18 | /// The new line delimiter. 19 | pub const NEW_LINE: u8 = b'\n'; 20 | 21 | /// A HTTP request. 22 | #[derive(Debug)] 23 | pub struct Request { 24 | pub(crate) headers: HashMap, 25 | pub(crate) method: Method, 26 | pub(crate) body: Body, 27 | pub(crate) url: Url, 28 | } 29 | 30 | impl Request { 31 | /// Returns a builder to produce a new `Request` with. This method panics if the URL is not 32 | /// valid. 33 | pub fn build(url: impl AsRef) -> builder::RequestBuilder { 34 | builder::RequestBuilder::new(url) 35 | } 36 | 37 | /// Try to construct a builder from the provided URL, and return an error if the URL is invalid. 38 | pub fn try_build(url: impl AsRef) -> Result { 39 | builder::RequestBuilder::try_new(url) 40 | } 41 | 42 | /// Parse a `Request` from the provided stream (which must implement `Read` and be valid for 43 | /// the `'static` lifetime.) This function will block until the `Request` has been parsed. 44 | /// 45 | /// Note that if the request is empty, this will not return an error – instead it will return 46 | /// `Ok(None)`. 47 | pub fn parse(stream: impl Read + 'static) -> Result, RequestParseError> { 48 | let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS]; 49 | let mut req = httparse::Request::new(&mut headers); 50 | 51 | let mut reader = BufReader::with_capacity(10000, stream); 52 | let mut buf = Vec::new(); 53 | 54 | loop { 55 | let bytes_read = match reader.read_until(NEW_LINE, &mut buf) { 56 | Ok(t) => t, 57 | Err(e) => { 58 | return Err(From::from(e)); 59 | } 60 | }; 61 | if bytes_read == 0 { 62 | return Ok(None); 63 | } 64 | // todo – drop requests for headers which are too large 65 | let idx = buf.len() - 1; 66 | if idx >= 3 && &buf[idx - 3..=idx] == b"\r\n\r\n" { 67 | break; 68 | } 69 | } 70 | 71 | let _ = req.parse(&buf)?; 72 | let method = Method::new_from_str(req.method.ok_or(RequestParseError::MissingMethod)?); 73 | let headers = { 74 | let mut map = HashMap::new(); 75 | for header in req.headers.iter() { 76 | map.insert( 77 | header.name.to_string(), 78 | std::str::from_utf8(header.value)?.to_string(), 79 | ); 80 | } 81 | map 82 | }; 83 | 84 | let url = 85 | if let Some((_, host)) = headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("host")) { 86 | let url = req.path.ok_or(RequestParseError::InvalidUrl)?; 87 | if url.starts_with("http://") || url.starts_with("https://") { 88 | Url::parse(url) 89 | } else if url.starts_with('/') { 90 | Url::parse(&format!("http://{}{}", host, url)) 91 | } else if req.method.unwrap().eq_ignore_ascii_case("connect") { 92 | Url::parse(&format!("http://{}/", host)) 93 | } else { 94 | return Err(RequestParseError::InvalidUrl); 95 | } 96 | .map_err(|_| RequestParseError::InvalidUrl)? 97 | } else { 98 | return Err(RequestParseError::MissingHeader("Host".to_string())); 99 | }; 100 | 101 | let body = Body::from_reader( 102 | reader, 103 | headers 104 | .iter() 105 | .find(|(key, _)| key.eq_ignore_ascii_case("content-length")) 106 | .and_then(|(_, len)| len.as_str().parse::().ok()), 107 | ); 108 | 109 | Ok(Some(Self { 110 | headers, 111 | method, 112 | body, 113 | url, 114 | })) 115 | } 116 | 117 | /// Write this `Request` into the provided writer. Note that this will modify the `Request` 118 | /// in-place; specifically, it will empty the contents of this `Request`'s body. 119 | pub fn write(&mut self, write: &mut impl Write) -> io::Result<()> { 120 | self.method.write(write)?; 121 | write!(write, " {} ", self.url.path())?; 122 | write!(write, "HTTP/1.1\r\n")?; 123 | for (key, value) in &self.headers { 124 | write!(write, "{}: {}\r\n", key, value)?; 125 | } 126 | write!(write, "\r\n")?; 127 | 128 | std::io::copy(&mut self.body, write).map(drop) 129 | } 130 | 131 | /// Get a reference to the request's headers. 132 | pub fn headers(&self) -> &HashMap { 133 | &self.headers 134 | } 135 | 136 | /// Get a reference to the request's method. 137 | pub fn method(&self) -> &Method { 138 | &self.method 139 | } 140 | 141 | /// Get a reference to the request's body. 142 | pub fn body(&self) -> &Body { 143 | &self.body 144 | } 145 | 146 | /// Replace the current `Body` with the supplied `Body`, returning the existing `Body`. 147 | pub fn replace_body(&mut self, body: impl Into) -> Body { 148 | let body = std::mem::replace(&mut self.body, body.into()); 149 | self.copy_content_type_from_body(); 150 | body 151 | } 152 | 153 | /// Take the `Body` from this request, replacing the `Request`'s body with an empty `Body`. 154 | pub fn take_body(&mut self) -> Body { 155 | self.replace_body(Body::empty()) 156 | } 157 | 158 | fn copy_content_type_from_body(&mut self) { 159 | self.headers 160 | .insert("Content-Type".into(), self.body.mime.to_string()); 161 | } 162 | 163 | /// Get a reference to the request's url. 164 | pub fn url(&self) -> &Url { 165 | &self.url 166 | } 167 | } 168 | 169 | #[derive(thiserror::Error, Debug)] 170 | /// An error encountered when trying to parse a request. 171 | pub enum RequestParseError { 172 | /// Couldn't parse the request in question. 173 | #[error("could not parse")] 174 | CouldNotParse(httparse::Error), 175 | /// A `Utf8Error` was encountered when parsing the request. 176 | #[error("utf8 error")] 177 | Utf8Error(Utf8Error), 178 | /// An `IoError` was encountered when parsing the request. 179 | #[error("io error")] 180 | IoError(io::Error), 181 | /// The URL supplied was not valid. 182 | #[error("the supplied url was invalid")] 183 | InvalidUrl, 184 | /// A header is missing. 185 | #[error("the `{0}` header is missing")] 186 | MissingHeader(String), 187 | /// The request method is missing. 188 | #[error("missing method")] 189 | MissingMethod, 190 | } 191 | 192 | impl From for RequestParseError { 193 | fn from(e: std::io::Error) -> Self { 194 | Self::IoError(e) 195 | } 196 | } 197 | 198 | impl From for RequestParseError { 199 | fn from(e: httparse::Error) -> Self { 200 | Self::CouldNotParse(e) 201 | } 202 | } 203 | 204 | impl From for RequestParseError { 205 | fn from(e: Utf8Error) -> Self { 206 | Self::Utf8Error(e) 207 | } 208 | } 209 | 210 | #[derive(PartialEq, Eq, Clone, Debug)] 211 | /// The HTTP method (e.g. "GET" or "POST") 212 | #[allow(missing_docs)] 213 | pub enum Method { 214 | Get, 215 | Post, 216 | Head, 217 | OtherMethod(String), 218 | } 219 | 220 | impl Method { 221 | /// Create a new method from the provided string. 222 | pub fn new_from_str(str: &str) -> Self { 223 | match str.to_ascii_lowercase().as_str() { 224 | "get" => Self::Get, 225 | "post" => Self::Post, 226 | _ => Self::OtherMethod(str.to_string()), 227 | } 228 | } 229 | 230 | /// Write the given message to a TCP stream. 231 | pub fn write(&self, write: &mut impl Write) -> io::Result<()> { 232 | let to_write = match self { 233 | Method::Get => "GET", 234 | Method::Post => "POST", 235 | Method::Head => "HEAD /", 236 | Method::OtherMethod(name) => name, 237 | }; 238 | write!(write, "{}", to_write) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /puck/src/response/builder.rs: -------------------------------------------------------------------------------- 1 | //! A `Response` builder. 2 | 3 | use std::collections::HashMap; 4 | use std::fmt::Debug; 5 | 6 | use crate::{body::Body, request::Method, Response}; 7 | 8 | /// Builds `Response`s. 9 | #[derive(Default)] 10 | #[must_use] 11 | pub struct ResponseBuilder { 12 | headers: HashMap, 13 | body: Option, 14 | status: Option, 15 | reason: Option, 16 | method: Option, 17 | } 18 | 19 | impl Debug for ResponseBuilder { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | f.debug_struct("ResponseBuilder") 22 | .field("headers", &self.headers) 23 | .field("status", &self.status) 24 | .field("reason", &self.reason) 25 | .field("method", &self.method) 26 | .finish_non_exhaustive() 27 | } 28 | } 29 | 30 | impl ResponseBuilder { 31 | /// Create a new `ResponseBuilder`. Equivalent to the `Default` implementation. 32 | pub fn new() -> Self { 33 | Self::default() 34 | } 35 | 36 | /// Set a header for this HTTP response. 37 | pub fn header(mut self, key: impl ToString, value: impl ToString) -> Self { 38 | self.headers.insert(key.to_string(), value.to_string()); 39 | self 40 | } 41 | 42 | /// Set a series of headers to this HTTP response from the provided iterator. 43 | pub fn headers(mut self, new_headers: impl IntoIterator) -> Self { 44 | self.headers.extend(new_headers); 45 | self 46 | } 47 | 48 | /// Set the `Body` for this HTTP response. 49 | pub fn body(mut self, body: impl Into) -> Self { 50 | self.body = Some(body.into()); 51 | self 52 | } 53 | 54 | /// Set the status for this `Response`. 55 | pub fn status(mut self, code: u16, reason: impl ToString) -> Self { 56 | self.status = Some(code); 57 | self.reason = Some(reason.to_string()); 58 | self 59 | } 60 | 61 | /// Build this HTTP response. This function will not panic. 62 | pub fn build(self) -> Response { 63 | Response { 64 | headers: self.headers, 65 | body: self.body.unwrap_or_else(Body::empty), 66 | status: self.status.unwrap_or(200), 67 | reason: self.reason.unwrap_or_default(), 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /puck/src/response/encoder.rs: -------------------------------------------------------------------------------- 1 | //! Encodes HTTP responses. 2 | 3 | use std::io::Write; 4 | 5 | use crate::Response; 6 | 7 | #[derive(Debug)] 8 | /// Encodes HTTP responses. 9 | pub struct Encoder { 10 | response: Response, 11 | } 12 | 13 | impl Encoder { 14 | /// Construct a new response encoder. 15 | pub fn new(response: Response) -> Self { 16 | Self { response } 17 | } 18 | 19 | /// Write the current response to the given stream. 20 | pub fn write_tcp_stream(&mut self, mut stream: impl Write) -> std::io::Result<()> { 21 | write!( 22 | stream, 23 | "HTTP/1.1 {} {}\r\n", 24 | self.response.status, self.response.reason 25 | )?; 26 | let mut headers = self.response.headers.iter().collect::>(); 27 | headers.sort_unstable_by_key(|(h, _)| h.as_str()); 28 | for (header, value) in headers { 29 | write!(stream, "{}: {}\r\n", header, value)?; 30 | } 31 | write!(stream, "\r\n")?; 32 | std::io::copy(&mut self.response.body, &mut stream)?; 33 | Ok(()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /puck/src/response/mod.rs: -------------------------------------------------------------------------------- 1 | //! HTTP responses. 2 | 3 | use std::{ 4 | collections::HashMap, 5 | io::{self, BufRead, BufReader, Read}, 6 | }; 7 | 8 | use crate::{ 9 | body::Body, 10 | request::{MAX_HEADERS, NEW_LINE}, 11 | }; 12 | 13 | use self::builder::ResponseBuilder; 14 | 15 | pub mod builder; 16 | pub mod encoder; 17 | 18 | /// A HTTP response. 19 | #[derive(Debug)] 20 | #[cfg_attr(feature = "fuzzing", derive(DefaultMutator, ToJson, FromJson))] 21 | pub struct Response { 22 | pub(crate) headers: HashMap, 23 | pub(crate) body: Body, 24 | pub(crate) status: u16, 25 | pub(crate) reason: String, 26 | } 27 | 28 | impl Response { 29 | fn copy_content_type_from_body(&mut self) { 30 | self.headers 31 | .insert("Content-Type".into(), self.body.mime.to_string()); 32 | } 33 | 34 | /// Replaces the current body with an empty one and returns the current body. 35 | pub fn replace_body(&mut self, body: impl Into) -> Body { 36 | let body = std::mem::replace(&mut self.body, body.into()); 37 | self.copy_content_type_from_body(); 38 | body 39 | } 40 | 41 | /// Obtain this `Response`'s `Body`, replacing the existing `Body` with an empty `Body`. 42 | pub fn take_body(&mut self) -> Body { 43 | self.replace_body(Body::empty()) 44 | } 45 | 46 | /// Return a new `ResponseBuilder`, with which you can construct a new `Response`. 47 | pub fn build() -> ResponseBuilder { 48 | ResponseBuilder::new() 49 | } 50 | 51 | /// Attempt to parse this `Response` from a stream (anything implementing `Read` that lives for 52 | /// `static`.) Note that if the response is empty, this function will return Ok(None), rather 53 | /// than an error. 54 | pub fn parse(stream: impl Read + 'static) -> Result, ParseResponseError> { 55 | let mut reader = BufReader::with_capacity(1000, stream); 56 | 57 | let mut headers = [httparse::EMPTY_HEADER; MAX_HEADERS]; 58 | let mut res = httparse::Response::new(&mut headers); 59 | let mut buf = Vec::new(); 60 | 61 | loop { 62 | let bytes_read = match reader.read_until(NEW_LINE, &mut buf) { 63 | Ok(t) => t, 64 | Err(e) => { 65 | return Err(From::from(e)); 66 | } 67 | }; 68 | if bytes_read == 0 { 69 | return Ok(None); 70 | } 71 | // todo – drop requests for headers which are too large 72 | let idx = buf.len() - 1; 73 | if idx >= 3 && &buf[idx - 3..=idx] == b"\r\n\r\n" { 74 | break; 75 | } 76 | } 77 | 78 | let _ = res.parse(&buf); 79 | 80 | let headers = { 81 | let mut map = HashMap::new(); 82 | for header in res.headers.iter() { 83 | map.insert( 84 | header.name.to_string(), 85 | match std::str::from_utf8(header.value) { 86 | Ok(t) => t, 87 | Err(_) => return Err(ParseResponseError::Utf8Error), 88 | } 89 | .to_string(), 90 | ); 91 | } 92 | map 93 | }; 94 | 95 | let status = if let Some(status) = res.code { 96 | status 97 | } else { 98 | return Err(ParseResponseError::MissingStatusCode); 99 | }; 100 | 101 | let reason = if let Some(reason) = res.reason { 102 | reason.to_string() 103 | } else { 104 | return Err(ParseResponseError::MissingReason); 105 | }; 106 | 107 | let body = Body::from_reader( 108 | reader, 109 | headers 110 | .iter() 111 | .find(|(key, _)| key.eq_ignore_ascii_case("content-type")) 112 | .and_then(|(_, len)| len.as_str().parse::().ok()), 113 | ); 114 | 115 | Ok(Some(Self { 116 | headers, 117 | body, 118 | status, 119 | reason, 120 | })) 121 | } 122 | 123 | /// Get a reference to the response's headers. 124 | pub fn headers(&self) -> &HashMap { 125 | &self.headers 126 | } 127 | 128 | /// Get a reference to the response's status. 129 | pub fn status(&self) -> &u16 { 130 | &self.status 131 | } 132 | 133 | /// Get a reference to the response's reason. 134 | pub fn reason(&self) -> &str { 135 | self.reason.as_str() 136 | } 137 | } 138 | 139 | #[derive(thiserror::Error, Debug)] 140 | /// An error encountered when parsing a `Response`. 141 | #[allow(missing_docs)] 142 | pub enum ParseResponseError { 143 | #[error("io error")] 144 | IoError(io::Error), 145 | #[error("the status code was not supplied")] 146 | MissingStatusCode, 147 | #[error("a reason was not supplied")] 148 | MissingReason, 149 | #[error("utf8 error")] 150 | Utf8Error, 151 | } 152 | 153 | impl From for ParseResponseError { 154 | fn from(error: io::Error) -> Self { 155 | Self::IoError(error) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /puck/src/ws/frame.rs: -------------------------------------------------------------------------------- 1 | //! WebSocket frame parsing. 2 | 3 | use std::io::{BufReader, Read, Write}; 4 | 5 | use byteorder::{NetworkEndian, ReadBytesExt, WriteBytesExt}; 6 | 7 | use super::message::Message; 8 | 9 | #[derive(Debug, Clone, PartialEq, Eq)] 10 | /// A WebSocket frame. 11 | pub struct Frame { 12 | pub(crate) fin: bool, 13 | pub(crate) rsv1: bool, 14 | pub(crate) rsv2: bool, 15 | pub(crate) rsv3: bool, 16 | pub(crate) op_code: OpCode, 17 | pub(crate) decoded: Vec, 18 | } 19 | 20 | impl Frame { 21 | /// Parse a frame from the given stream. 22 | pub fn parse(stream: impl Read) -> Result { 23 | let mut bufread = BufReader::new(stream); 24 | 25 | let (first, second) = { 26 | let mut buffer = [0_u8; 2]; 27 | if bufread.read(&mut buffer)? != 2 { 28 | return Err(ParseFrameError::InsufficientData); 29 | } 30 | (buffer[0], buffer[1]) 31 | }; 32 | 33 | let fin = first & 0x80 != 0; 34 | 35 | let rsv1 = first & 0x40 != 0; 36 | let rsv2 = first & 0x20 != 0; 37 | let rsv3 = first & 0x10 != 0; 38 | 39 | let op_code = match first & 0x0F { 40 | 0 => OpCode::Continue, 41 | 1 => OpCode::Text, 42 | 2 => OpCode::Binary, 43 | _i @ 3..=7 => OpCode::Reserved, 44 | 8 => OpCode::Terminate, 45 | 9 => OpCode::Ping, 46 | 10 => OpCode::Pong, 47 | _i @ 11..=15 => OpCode::Reserved, 48 | _ => return Err(ParseFrameError::InvalidOpCode), 49 | }; 50 | 51 | let masked = second & 0x80 != 0; 52 | 53 | let payload_length = match second & 0x7F { 54 | 126 => bufread.read_uint::(2)?, 55 | 127 => bufread.read_uint::(8)?, 56 | i => i as u64, 57 | }; 58 | 59 | if payload_length > 0 && !masked { 60 | return Err(ParseFrameError::MaskNotSet); 61 | } 62 | 63 | let decoded = if payload_length > 0 { 64 | let mut masking_key = [0_u8; 4]; 65 | if bufread.read(&mut masking_key)? != 4 { 66 | return Err(ParseFrameError::InsufficientData); 67 | } 68 | 69 | let mut encoded = Vec::with_capacity(payload_length as usize); 70 | 71 | let n = bufread.take(payload_length).read_to_end(&mut encoded)?; 72 | if n != payload_length as usize { 73 | if !fin && payload_length > n as u64 { 74 | return Err(ParseFrameError::WaitForNextFrame(payload_length - n as u64)); 75 | } 76 | return Err(ParseFrameError::IoError); 77 | } 78 | 79 | let mut decoded = vec![0_u8; payload_length as usize]; 80 | for i in 0..encoded.len() { 81 | decoded[i] = encoded[i] ^ masking_key[i % 4]; 82 | } 83 | 84 | decoded 85 | } else { 86 | vec![] 87 | }; 88 | 89 | Ok(Self { 90 | fin, 91 | rsv1, 92 | rsv2, 93 | rsv3, 94 | op_code, 95 | decoded, 96 | }) 97 | } 98 | 99 | /// Get a reference to the frame's fin. 100 | pub fn fin(&self) -> &bool { 101 | &self.fin 102 | } 103 | 104 | /// Get a reference to the frame's op code. 105 | pub fn op_code(&self) -> &OpCode { 106 | &self.op_code 107 | } 108 | 109 | /// Get a reference to the frame's decoded. 110 | pub fn decoded(&self) -> &Vec { 111 | &self.decoded 112 | } 113 | 114 | /// Returns the decoded data from this frame. 115 | pub fn take_decoded(self) -> Vec { 116 | self.decoded 117 | } 118 | 119 | pub(crate) fn format(&self, to: &mut impl Write) -> std::io::Result<()> { 120 | let code = self.op_code.code(); 121 | 122 | let one = code 123 | | if self.fin { 0x80 } else { 0 } 124 | | if self.rsv1 { 0x40 } else { 0 } 125 | | if self.rsv2 { 0x20 } else { 0 } 126 | | if self.rsv3 { 0x10 } else { 0 }; 127 | 128 | let two = { Self::format_length(self.decoded.len() as u64) }; 129 | 130 | to.write_all(&[one, two])?; 131 | 132 | let len = self.decoded.len(); 133 | 134 | if len >= 126_usize && len < u16::MAX as usize { 135 | to.write_u16::(len as u16)?; 136 | } else if len >= 126 { 137 | to.write_u64::(len as u64)?; 138 | } 139 | 140 | Ok(()) 141 | } 142 | 143 | pub(crate) fn format_length(len: u64) -> u8 { 144 | if len < 126 { 145 | len as u8 146 | } else if len < u16::MAX as u64 { 147 | 126 148 | } else { 149 | 127 150 | } 151 | } 152 | } 153 | 154 | #[derive(Debug, Copy, Clone, Eq, PartialEq)] 155 | /// The operation code (from the specification) of this frame. 156 | #[allow(missing_docs)] 157 | pub enum OpCode { 158 | Continue, 159 | Binary, 160 | Text, 161 | Reserved, 162 | Terminate, 163 | Ping, 164 | Pong, 165 | } 166 | 167 | impl OpCode { 168 | fn code(self) -> u8 { 169 | match self { 170 | OpCode::Continue => 0, 171 | OpCode::Binary => 2, 172 | OpCode::Text => 1, 173 | OpCode::Reserved => 3, 174 | OpCode::Terminate => 8, 175 | OpCode::Ping => 9, 176 | OpCode::Pong => 10, 177 | } 178 | } 179 | } 180 | 181 | #[derive(thiserror::Error, Debug)] 182 | /// An error encountered when trying to parse a `Frame`. 183 | #[allow(missing_docs)] 184 | pub enum ParseFrameError { 185 | #[error("mask not set")] 186 | MaskNotSet, 187 | #[error("io error")] 188 | IoError, 189 | #[error("invalid op code")] 190 | InvalidOpCode, 191 | #[error("not enough data supplied")] 192 | InsufficientData, 193 | #[error("wait for next frame")] 194 | WaitForNextFrame(u64), 195 | } 196 | 197 | impl From for ParseFrameError { 198 | fn from(_: std::io::Error) -> Self { 199 | Self::IoError 200 | } 201 | } 202 | 203 | impl From for Frame { 204 | fn from(msg: Message) -> Self { 205 | Self { 206 | fin: true, 207 | rsv1: false, 208 | rsv2: false, 209 | rsv3: false, 210 | op_code: match msg { 211 | Message::Ping(_) => OpCode::Ping, 212 | Message::Pong(_) => OpCode::Pong, 213 | Message::Text(_) => OpCode::Text, 214 | Message::Binary(_) => OpCode::Binary, 215 | }, 216 | decoded: match msg { 217 | Message::Text(string) => string.into_bytes(), 218 | Message::Binary(bin) => bin, 219 | Message::Ping(payload) | Message::Pong(payload) => payload.unwrap_or_default(), 220 | }, 221 | } 222 | } 223 | } 224 | 225 | #[cfg(test)] 226 | mod test_parse_frames { 227 | use std::io::Cursor; 228 | 229 | use crate::ws::frame::Frame; 230 | 231 | #[lunatic::test] 232 | fn test_parse_frames() { 233 | assert_eq!( 234 | Frame::parse(Cursor::new([137, 0,])).unwrap(), 235 | Frame { 236 | fin: true, 237 | rsv1: false, 238 | rsv2: false, 239 | rsv3: false, 240 | op_code: crate::ws::frame::OpCode::Ping, 241 | decoded: vec![] 242 | } 243 | ); 244 | 245 | assert_eq!( 246 | Frame::parse(Cursor::new([138, 0,])).unwrap(), 247 | Frame { 248 | fin: true, 249 | rsv1: false, 250 | rsv2: false, 251 | rsv3: false, 252 | op_code: crate::ws::frame::OpCode::Pong, 253 | decoded: vec![] 254 | } 255 | ); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /puck/src/ws/message.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | 3 | use log::trace; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::ws::frame::Frame; 7 | 8 | use super::frame::{OpCode, ParseFrameError}; 9 | 10 | #[derive(Debug, Clone, Serialize, Deserialize)] 11 | /// A WebSocket message. 12 | pub enum Message { 13 | /// A ping. 14 | Ping(Option>), 15 | /// A pong. 16 | Pong(Option>), 17 | /// A text message. 18 | Text(String), 19 | /// A binary message. 20 | Binary(Vec), 21 | } 22 | 23 | impl Message { 24 | /// Parse the next message from the stream. 25 | pub fn next(stream: impl Read + Clone) -> Result { 26 | log::trace!("trying to parse next message"); 27 | let first = Frame::parse(stream.clone())?; 28 | 29 | if first.op_code() == &OpCode::Terminate { 30 | trace!("Client asked to close connection"); 31 | return Err(DecodeMessageError::ClientSentCloseFrame); 32 | } 33 | 34 | if *first.fin() { 35 | return match first.op_code() { 36 | crate::ws::frame::OpCode::Binary => Ok(Self::Binary(first.take_decoded())), 37 | crate::ws::frame::OpCode::Text => Ok(Self::Text( 38 | String::from_utf8(first.take_decoded()) 39 | .map_err(|_| DecodeMessageError::ClientProtocolViolationError)?, 40 | )), 41 | crate::ws::frame::OpCode::Ping => { 42 | let payload = first.take_decoded(); 43 | Ok(Self::Ping(if !payload.is_empty() { 44 | Some(payload) 45 | } else { 46 | None 47 | })) 48 | } 49 | crate::ws::frame::OpCode::Pong => { 50 | let payload = first.take_decoded(); 51 | Ok(Self::Pong(if !payload.is_empty() { 52 | Some(payload) 53 | } else { 54 | None 55 | })) 56 | } 57 | _ => Err(DecodeMessageError::ClientProtocolViolationError), 58 | }; 59 | } 60 | 61 | let op_code = *first.op_code(); 62 | 63 | let mut payload = first.take_decoded(); 64 | 65 | loop { 66 | let msg = Frame::parse(stream.clone())?; 67 | 68 | if msg.op_code() == &OpCode::Terminate { 69 | trace!("Client asked to close connection"); 70 | return Err(DecodeMessageError::ClientSentCloseFrame); 71 | } 72 | 73 | if msg.op_code() != &OpCode::Continue { 74 | return Err(DecodeMessageError::ClientProtocolViolationError); 75 | } 76 | 77 | let fin = *msg.fin(); 78 | 79 | payload.extend(msg.take_decoded()); 80 | 81 | if fin { 82 | return match op_code { 83 | crate::ws::frame::OpCode::Binary => Ok(Self::Binary(payload)), 84 | crate::ws::frame::OpCode::Text => { 85 | Ok(Self::Text(String::from_utf8(payload).map_err(|_| { 86 | DecodeMessageError::ClientProtocolViolationError 87 | })?)) 88 | } 89 | crate::ws::frame::OpCode::Ping => Ok(Self::Ping(if !payload.is_empty() { 90 | Some(payload) 91 | } else { 92 | None 93 | })), 94 | crate::ws::frame::OpCode::Pong => Ok(Self::Pong(if !payload.is_empty() { 95 | Some(payload) 96 | } else { 97 | None 98 | })), 99 | _ => Err(DecodeMessageError::ClientProtocolViolationError), 100 | }; 101 | } 102 | } 103 | } 104 | } 105 | 106 | #[derive(thiserror::Error, Debug)] 107 | /// An error encountered when trying to decode a WebSocket message. 108 | #[allow(missing_docs)] 109 | pub enum DecodeMessageError { 110 | #[error("the client violated the WebSocket protocol")] 111 | ClientProtocolViolationError, 112 | #[error("the client wants to close the connection")] 113 | ClientSentCloseFrame, 114 | } 115 | 116 | impl From for DecodeMessageError { 117 | fn from(_: ParseFrameError) -> Self { 118 | Self::ClientProtocolViolationError 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /puck/src/ws/mod.rs: -------------------------------------------------------------------------------- 1 | //! Puck WebSocket support. 2 | 3 | /// A WebSocket frame. 4 | pub mod frame; 5 | /// A WebSocket message. 6 | pub mod message; 7 | pub mod send; 8 | /// Upgrade an HTTP connection to a WebSocket connection. 9 | pub mod upgrade; 10 | /// The WebSocket implementation. 11 | pub mod websocket; 12 | 13 | pub use upgrade::*; 14 | -------------------------------------------------------------------------------- /puck/src/ws/send.rs: -------------------------------------------------------------------------------- 1 | //! Send WebSocket messages to the client. 2 | 3 | use std::io::Write; 4 | 5 | use super::{frame::Frame, message::Message}; 6 | 7 | /// Send a message to the provided stream. 8 | pub(crate) fn send(stream: impl Write, msg: Message) -> Result<(), SendFrameError> { 9 | send_frame(stream, Frame::from(msg)) 10 | } 11 | 12 | pub(crate) fn send_frame(mut stream: impl Write, frame: Frame) -> Result<(), SendFrameError> { 13 | frame.format(&mut stream)?; 14 | stream.write_all(frame.decoded())?; 15 | 16 | Ok(()) 17 | } 18 | 19 | #[derive(thiserror::Error, Debug)] 20 | /// An error encountered when sending a frame. 21 | pub enum SendFrameError { 22 | #[error("error encoding the frame")] 23 | /// Indicates that the frame in question could not be encoded. 24 | EncodeFrameError, 25 | #[error("io error")] 26 | /// Indicates that there was an IO error when trying to handle this frame. 27 | IoError, 28 | } 29 | 30 | impl From for SendFrameError { 31 | fn from(_: std::io::Error) -> Self { 32 | Self::IoError 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /puck/src/ws/upgrade.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use base64::encode; 4 | use log::trace; 5 | use sha1::{Digest, Sha1}; 6 | 7 | use crate::{err_400, write_response, Response}; 8 | 9 | /// Compute whether or not this request can be upgraded. 10 | pub fn should_upgrade(req: &crate::Request) -> bool { 11 | req.headers 12 | .get("Upgrade") 13 | .map(|val| val.to_ascii_lowercase() == "websocket") 14 | .unwrap_or_default() 15 | // todo: read the spec to check if the check for the upgrade header is correct! 16 | && req 17 | .headers 18 | .get("Connection") 19 | .map(|val| val.to_ascii_lowercase().contains("upgrade")) 20 | .unwrap_or_default() 21 | } 22 | 23 | /// The 'magic string' used to upgrade WebSocket connections. 24 | const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; 25 | 26 | /// Tries to upgrade the connection to a WebSocket connection. 27 | /// 28 | /// Returns true if this is successful, and false if it is not. Automatically sends a 400 Bad 29 | /// Request response if the request fails. 30 | pub fn perform_upgrade(req: &crate::Request, stream: impl Write) -> bool { 31 | let key = match req.headers.get("Sec-WebSocket-Key") { 32 | Some(t) => t, 33 | None => { 34 | trace!("Rejecting WebSocket upgrade because of missing `Sec-WebSocket-Key` header."); 35 | write_response(err_400(), stream); 36 | return false; 37 | } 38 | }; 39 | 40 | let result = compute_accept_header(key.clone()); 41 | 42 | write_response( 43 | Response::build() 44 | .header("Sec-WebSocket-Accept", result) 45 | .header("Upgrade", "websocket") 46 | .header("Connection", "Upgrade") 47 | .status(101, "Web Socket Protocol Handshake") 48 | .build(), 49 | stream, 50 | ); 51 | 52 | true 53 | } 54 | 55 | fn compute_accept_header(key: String) -> String { 56 | let to_hash = key + GUID; 57 | 58 | let mut sha1 = Sha1::new(); 59 | sha1.update(to_hash.as_bytes()); 60 | let result = sha1.finalize(); 61 | encode(result) 62 | } 63 | 64 | #[cfg(test)] 65 | mod test { 66 | use crate::ws::upgrade::compute_accept_header; 67 | 68 | #[lunatic::test] 69 | fn test_compute_upgrade_header() { 70 | assert_eq!( 71 | compute_accept_header("dGhlIHNhbXBsZSBub25jZQ==".to_string()), 72 | "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=".to_string() 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /puck/src/ws/websocket.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use log::trace; 4 | use lunatic::net::TcpStream; 5 | 6 | use crate::core::UsedStream; 7 | 8 | use super::{ 9 | frame::Frame, 10 | message::Message, 11 | send::{self, send_frame, SendFrameError}, 12 | }; 13 | 14 | #[derive(Debug, serde::Serialize, serde::Deserialize)] 15 | /// A WebSocket connection over a duplex stream. 16 | /// 17 | /// note: this _can_ be sent from one process to another, but it is intended that this struct 18 | /// only be used from one process at once 19 | #[must_use] 20 | pub struct WebSocket { 21 | stream: TcpStream, 22 | state: WebSocketState, 23 | } 24 | 25 | #[derive(Debug, serde::Serialize, serde::Deserialize, Clone, Copy)] 26 | /// The state of the WebSocket connection (either open or closed). 27 | pub enum WebSocketState { 28 | /// The connection is open. 29 | Open, 30 | /// The connection has been closed. 31 | Closed, 32 | } 33 | 34 | impl WebSocket { 35 | /// Create a new WebSocket connection listening on the provided stream. 36 | pub fn new(stream: TcpStream) -> Self { 37 | Self { 38 | stream, 39 | state: WebSocketState::Open, 40 | } 41 | } 42 | 43 | /// Send a message to the other party. 44 | pub fn send(&self, message: Message) -> Result<(), SendFrameError> { 45 | send::send(self.clone_stream(), message) 46 | } 47 | 48 | /// Send a message to a stream. 49 | pub fn send_to_stream(stream: TcpStream, message: Message) -> Result<(), SendFrameError> { 50 | send::send(stream, message) 51 | } 52 | 53 | /// Return the underlying stream. 54 | fn clone_stream(&self) -> TcpStream { 55 | self.stream.clone() 56 | } 57 | 58 | /// Close the WebSocket connection. 59 | pub fn close(self) -> Result { 60 | match self.state { 61 | WebSocketState::Open => { 62 | send_close_frame(self.stream.clone()); 63 | } 64 | WebSocketState::Closed => {} 65 | }; 66 | 67 | Ok(UsedStream { 68 | stream: Some(self.stream), 69 | keep_alive: false, 70 | }) 71 | } 72 | 73 | /// You probably don't want to use this. 74 | pub fn make_copy(&self) -> WebSocket { 75 | Self { 76 | stream: self.stream.clone(), 77 | state: self.state, 78 | } 79 | } 80 | } 81 | 82 | impl Iterator for WebSocket { 83 | type Item = Result; 84 | 85 | fn next(&mut self) -> Option> { 86 | Some(match self.state { 87 | WebSocketState::Open => match Message::next(self.stream.clone()) { 88 | Ok(msg) => { 89 | if let Message::Ping(ref payload) = msg { 90 | send_frame( 91 | self.stream.clone(), 92 | Frame { 93 | fin: true, 94 | rsv1: false, 95 | rsv2: false, 96 | rsv3: false, 97 | op_code: super::frame::OpCode::Pong, 98 | decoded: payload.clone().unwrap_or_default(), 99 | }, 100 | ) 101 | .expect("failed to send pong"); 102 | } 103 | Ok(msg) 104 | } 105 | Err(e) => match e { 106 | super::message::DecodeMessageError::ClientProtocolViolationError => { 107 | Err(NextMessageError::ClientError) 108 | } 109 | super::message::DecodeMessageError::ClientSentCloseFrame => { 110 | self.state = WebSocketState::Closed; 111 | send_close_frame(self.stream.clone()); 112 | Err(NextMessageError::ConnectionClosed) 113 | } 114 | }, 115 | }, 116 | WebSocketState::Closed => Err(NextMessageError::ConnectionClosed), 117 | }) 118 | } 119 | } 120 | 121 | #[derive(thiserror::Error, Debug, serde::Serialize, serde::Deserialize)] 122 | /// An error encountered when trying to lift the next message from the stream. 123 | pub enum NextMessageError { 124 | #[error("malformed client")] 125 | /// The client sent an invalid request. 126 | ClientError, 127 | #[error("the connection has been closed")] 128 | /// The connection is already closed. 129 | ConnectionClosed, 130 | } 131 | 132 | fn send_close_frame(stream: impl Write) { 133 | trace!("Sending close frame"); 134 | send_frame( 135 | stream, 136 | Frame { 137 | fin: true, 138 | rsv1: false, 139 | rsv2: false, 140 | rsv3: false, 141 | op_code: super::frame::OpCode::Terminate, 142 | decoded: vec![], 143 | }, 144 | ) 145 | .expect("failed to send close frame"); 146 | } 147 | -------------------------------------------------------------------------------- /puck_liveview/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "puck_liveview" 3 | version = "0.1.0" 4 | authors = ["teymour-aldridge "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | derive_builder = "0.11.2" 9 | puck = { path = "../puck" } 10 | serde = { version = "1.0.138", features = ["derive"] } 11 | serde_json = "1.0.82" 12 | lunatic = "0.9.1" 13 | fuzzcheck = { version = "0.12.0", optional = true } 14 | 15 | [target.'cfg(not(target_arch="wasm32"))'.dependencies.malvolio] 16 | git = "https://github.com/bailion/malvolio/" 17 | rev = "2bba3de" 18 | features = ["pub_fields", "fuzz"] 19 | version = "0.3.1" 20 | 21 | [target.'cfg(target_arch="wasm32")'.dependencies.malvolio] 22 | git = "https://github.com/bailion/malvolio/" 23 | rev = "2bba3de" 24 | features = ["pub_fields"] 25 | version = "0.3.1" 26 | 27 | [dev-dependencies] 28 | insta = "1.15.0" 29 | scraper = "0.13.0" 30 | serde_json = "1.0.82" 31 | 32 | [features] 33 | apply = [] 34 | _fuzz = ["apply", "fuzzcheck"] 35 | -------------------------------------------------------------------------------- /puck_liveview/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

Loading...

10 | 11 | 12 | -------------------------------------------------------------------------------- /puck_liveview/client/index.js: -------------------------------------------------------------------------------- 1 | let ws = new WebSocket("ws://" + window.location.host + window.location.pathname + "ws"); 2 | 3 | ws.onmessage = (msg) => { 4 | let data = JSON.parse(msg.data); 5 | data.map((data) => { 6 | if (data.ty === "createTag") { 7 | console.log("create tag"); 8 | let split = data["payload"].split("+"); 9 | let el = document.createElement(split[0]); 10 | el.id = data["el"]; 11 | if (!split[1] || split[1] === "") { 12 | document.body.appendChild(el); 13 | } else { 14 | document.getElementById(split[1]).appendChild(el); 15 | } 16 | } else if (data.ty === "setAttr") { 17 | console.log("set attribute"); 18 | console.log(document.getElementById(data.el)); 19 | let split = data["payload"].split("+"); 20 | console.log(split[1]); 21 | let el = document.getElementById(data.el); 22 | el.setAttribute(split[0], split[1]); 23 | } else if (data.ty == "removeAttr") { 24 | document.getElementById(data.el).removeAttribute(data.payload); 25 | } else if (data.ty === "setTagName") { 26 | let old_tag = document.getElementById(data.el); 27 | let new_tag = document.createElement(data.payload); 28 | new_tag.innerHTML = old_tag.innerHTML; 29 | for (var i = 0, l = old_tag.attributes.length; i < l; ++i) { 30 | let name = old_tag.attributes.item(i).nodeName; 31 | var value = old_tag.attributes.item(i).nodeValue; 32 | 33 | new_tag.setAttribute(name, value); 34 | } 35 | old_tag.parentNode.replaceChild(new_tag, old_tag); 36 | } else if (data.ty === "setText") { 37 | console.log(data); 38 | document.getElementById(data["el"]).textContent = data["payload"]; 39 | } else if (data.ty === "attachListener") { 40 | let split = data.payload.split("+"); 41 | document.getElementById(data.el).addEventListener(split[1], function (e) { 42 | console.log("sending listener data"); 43 | console.log(split); 44 | console.log(data); 45 | if (split[1] === "click") { 46 | console.log("sending click data"); 47 | ws.send(JSON.stringify({ 48 | listener: split[0] 49 | })); 50 | } else if (split[1] === "submit") { 51 | ws.send(JSON.stringify({ 52 | listener: split[0] 53 | })); 54 | } else if (split[1] === "input") { 55 | console.log("sending input data"); 56 | ws.send(JSON.stringify({ 57 | listener: split[0], 58 | payload: { 59 | value: e.target.value 60 | } 61 | })); 62 | } 63 | }) 64 | } else if (data.ty === "removeListeners") { 65 | let old = document.getElementById(data.el); 66 | let new_el = old.cloneNode(true); 67 | old.parentNode.replaceChild(new_el, old); 68 | } else if (data.ty === "deleteEl") { 69 | let el = document.getElementById(data.el); 70 | console.log("deleting"); 71 | console.log(el); 72 | el.parentNode.removeChild(el); 73 | } else if (data.ty === "setId") { 74 | console.log("updating id, from: " + data.el + ", to: " + data.payload); 75 | document.getElementById(data.el).id = data.payload; 76 | } 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /puck_liveview/src/client/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | mod send_changeset; 4 | 5 | #[derive(Serialize, Deserialize, Debug)] 6 | pub struct ClientMessage { 7 | pub(crate) listener: String, 8 | pub(crate) payload: Option, 9 | } 10 | 11 | #[derive(Serialize, Deserialize, Debug)] 12 | pub struct ClientMessagePayload { 13 | pub(crate) value: String, 14 | } 15 | -------------------------------------------------------------------------------- /puck_liveview/src/client/send_changeset.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /puck_liveview/src/component/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use lunatic::{Mailbox, Process}; 4 | use puck::ws::{ 5 | message::Message, 6 | websocket::{NextMessageError, WebSocket}, 7 | }; 8 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 9 | 10 | use crate::{ 11 | client::ClientMessage, 12 | dom::{ 13 | element::{diff::changeset::instruction_serializer::InstructionSerializer, Element}, 14 | event::{ClickEvent, InputEvent, SubmitEvent}, 15 | listener::Listener, 16 | }, 17 | }; 18 | 19 | /// todo: documentation 20 | pub trait Component 21 | where 22 | INPUT: Serialize + DeserializeOwned, 23 | { 24 | fn new(data: DATA, context: &Context) -> Self; 25 | 26 | fn update(&mut self, input: INPUT, context: &Context); 27 | 28 | fn render(&self) -> (Element, HashMap>); 29 | } 30 | 31 | #[derive(Debug)] 32 | pub struct Context 33 | where 34 | INPUT: serde::Serialize + serde::de::DeserializeOwned, 35 | { 36 | proc_id: Process, 37 | } 38 | 39 | impl Context 40 | where 41 | INPUT: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug, 42 | { 43 | pub fn process(&self) -> Process { 44 | self.proc_id.clone() 45 | } 46 | } 47 | 48 | /// Sets up the provided [Component] for communication over the WebSocket stream. The `Process` 49 | /// returned can be used to send messages to the component. 50 | // todo: simple code example 51 | pub fn manage(start_data: DATA, stream: WebSocket) -> Process 52 | where 53 | DATA: serde::Serialize + serde::de::DeserializeOwned, 54 | INPUT: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug, 55 | COMPONENT: Component, 56 | { 57 | Process::spawn::<(DATA, WebSocket), Mailbox>( 58 | (start_data, stream), 59 | |(start_data, stream), mailbox| { 60 | // spawn new process 61 | // todo: maybe have a supervisor 62 | let process = Process::spawn( 63 | (start_data, stream.make_copy()), 64 | main_loop::, 65 | ); 66 | 67 | process.send(WsOrInput::WhoAmI(mailbox.this())); 68 | 69 | Process::spawn( 70 | (stream, process.clone()), 71 | |(mut websocket, process), _: Mailbox<()>| loop { 72 | loop { 73 | let msg = websocket.next(); 74 | if let Some(msg) = msg { 75 | process.send(WsOrInput::Ws(msg)) 76 | } 77 | } 78 | }, 79 | ); 80 | 81 | // forward all messages that this process receives to the child process 82 | loop { 83 | let msg = mailbox.receive(); 84 | process.send(WsOrInput::Input(msg)); 85 | } 86 | }, 87 | ) 88 | } 89 | 90 | fn main_loop( 91 | (start_data, stream): (DATA, WebSocket), 92 | mailbox: Mailbox>, 93 | ) where 94 | DATA: serde::Serialize + serde::de::DeserializeOwned, 95 | INPUT: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug, 96 | COMPONENT: Component, 97 | { 98 | let context = Context { 99 | proc_id: match mailbox.receive() { 100 | WsOrInput::WhoAmI(p) => p, 101 | _ => unreachable!(), 102 | }, 103 | }; 104 | 105 | let mut component = COMPONENT::new(start_data, &context); 106 | 107 | let (mut old_dom, mut old_listeners) = component.render(); 108 | 109 | let instructions = old_dom.diff(None); 110 | 111 | let payload = serde_json::to_string(&InstructionSerializer(instructions)).unwrap(); 112 | 113 | stream.send(Message::Text(payload)).unwrap(); 114 | 115 | loop { 116 | let msg = mailbox.receive(); 117 | match msg { 118 | WsOrInput::Ws(msg) => { 119 | if let Ok(Message::Text(contents)) = msg { 120 | // todo: this API is just plain messy 121 | if let Ok(t) = serde_json::from_str::(&contents) { 122 | if let Some(listener) = old_listeners.get(&t.listener) { 123 | match listener { 124 | Listener::Click { call } => { 125 | let input = (call)(ClickEvent); 126 | component.update(input, &context); 127 | perform_diff( 128 | &component, 129 | &mut old_dom, 130 | &stream, 131 | &mut old_listeners, 132 | ); 133 | } 134 | Listener::Submit { call } => { 135 | let input = (call)(SubmitEvent); 136 | component.update(input, &context); 137 | perform_diff( 138 | &component, 139 | &mut old_dom, 140 | &stream, 141 | &mut old_listeners, 142 | ); 143 | } 144 | Listener::Input { call } => { 145 | if let Some(payload) = t.payload { 146 | let input = (call)(InputEvent { 147 | value: payload.value, 148 | }); 149 | component.update(input, &context); 150 | perform_diff( 151 | &component, 152 | &mut old_dom, 153 | &stream, 154 | &mut old_listeners, 155 | ); 156 | } 157 | } 158 | } 159 | } 160 | } else { 161 | { 162 | continue; 163 | } 164 | }; 165 | } else if let Err(e) = msg { 166 | match e { 167 | // todo: what should we do here? 168 | NextMessageError::ClientError => {} 169 | NextMessageError::ConnectionClosed => { 170 | drop(component); 171 | return; 172 | } 173 | } 174 | } 175 | } 176 | WsOrInput::Input(input) => { 177 | component.update(input, &context); 178 | 179 | perform_diff(&component, &mut old_dom, &stream, &mut old_listeners); 180 | } 181 | WsOrInput::WhoAmI(_) => { 182 | unreachable!() 183 | } 184 | } 185 | } 186 | } 187 | 188 | fn perform_diff( 189 | component: &COMPONENT, 190 | old_dom: &mut Element, 191 | stream: &WebSocket, 192 | old_listeners: &mut HashMap>, 193 | ) where 194 | DATA: serde::Serialize + serde::de::DeserializeOwned, 195 | INPUT: serde::Serialize + serde::de::DeserializeOwned, 196 | COMPONENT: Component, 197 | { 198 | let (mut new_dom, mut new_listeners) = component.render(); 199 | let instructions = old_dom.diff(Some(&new_dom)); 200 | let _ = stream.send(Message::Text( 201 | serde_json::to_string(&InstructionSerializer(instructions)).unwrap(), 202 | )); 203 | std::mem::swap(old_dom, &mut new_dom); 204 | std::mem::swap(old_listeners, &mut new_listeners); 205 | } 206 | 207 | #[derive(serde::Serialize, serde::Deserialize)] 208 | #[serde(bound = "INPUT: serde::Serialize + for<'de2> serde::Deserialize<'de2>")] 209 | enum WsOrInput 210 | where 211 | INPUT: serde::Serialize + for<'de2> Deserialize<'de2> + std::fmt::Debug, 212 | { 213 | Ws(Result), 214 | Input(INPUT), 215 | // a process whose messages are sent to the Liveview component 216 | WhoAmI(Process), 217 | } 218 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/element/diff/changeset/apply.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::num::ParseIntError; 3 | 4 | use crate::dom::element::Element; 5 | use crate::dom::listener::ListenerRef; 6 | 7 | use super::Changeset; 8 | 9 | impl<'a> Changeset<'a> { 10 | /// Apply the changeset to an `Element` in-place. 11 | /// 12 | /// **This method is only available if you have activated the `apply` feature.** 13 | /// 14 | /// This method is probably not useful to you – it is here for testing purposes. 15 | pub fn apply(&self, element: &mut Element) { 16 | for op in self.ops.iter() { 17 | let el_id = Self::parse(op.id.clone()); 18 | match &op.instruction { 19 | super::Instruction::InsertChild { new_child_id } => { 20 | let el = 21 | Self::find_el_with_id(el_id, element).expect("failed to find the element"); 22 | 23 | el.children.push(Element { 24 | id: Self::parse(new_child_id), 25 | ..Default::default() 26 | }) 27 | } 28 | super::Instruction::InsertAfter { after_id } => { 29 | let el = Self::find_el_with_id(el_id, element).expect("failed to find element"); 30 | 31 | assert!(!el.children.is_empty()); 32 | 33 | let after_id = Self::parse(after_id); 34 | 35 | let index = el 36 | .children 37 | .iter() 38 | .enumerate() 39 | .find_map( 40 | |(index, el)| { 41 | if el.id == after_id { 42 | Some(index) 43 | } else { 44 | None 45 | } 46 | }, 47 | ) 48 | .expect("`InsertAfter` - specified node to insert after was not found in children"); 49 | 50 | el.children.insert( 51 | index + 1, 52 | Element { 53 | id: after_id, 54 | ..Default::default() 55 | }, 56 | ); 57 | } 58 | super::Instruction::InsertBefore { before_id } => { 59 | let el = Self::find_el_with_id(el_id, element).expect("failed to find element"); 60 | 61 | assert!(!el.children.is_empty()); 62 | 63 | let before_id = Self::parse(before_id); 64 | 65 | let index = el 66 | .children 67 | .iter() 68 | .enumerate() 69 | .find_map( 70 | |(index, el)| { 71 | if el.id == before_id { 72 | Some(index) 73 | } else { 74 | None 75 | } 76 | }, 77 | ) 78 | .expect("`InsertAfter` - specified node to insert after was not found in children"); 79 | 80 | el.children.insert( 81 | index, 82 | Element { 83 | id: before_id, 84 | ..Default::default() 85 | }, 86 | ); 87 | } 88 | super::Instruction::SetAttribute { key, value } => { 89 | let el = Self::find_el_with_id(el_id.clone(), element) 90 | .expect("failed to find element to set attribute of"); 91 | 92 | el.attributes 93 | .insert(key.clone().into_owned(), value.clone().into_owned()); 94 | } 95 | super::Instruction::SetId { value } => { 96 | Self::find_and_mutate(element, el_id, |el| { 97 | el.id = Self::parse(value); 98 | }) 99 | } 100 | super::Instruction::SetText { value } => { 101 | Self::find_and_mutate(element, el_id, |el| { 102 | el.text = Some(Self::crudely_remove_cow_lifetime_problems(value)); 103 | }) 104 | } 105 | super::Instruction::SetTagName { name } => { 106 | Self::find_and_mutate(element, el_id, |el| { 107 | el.name = Self::crudely_remove_cow_lifetime_problems(name); 108 | }) 109 | } 110 | super::Instruction::CreateTag { name, parent_id } => Self::find_and_mutate( 111 | element, 112 | parent_id.as_ref().unwrap().parse::().unwrap(), 113 | |parent| { 114 | parent.children.push(Element { 115 | id: el_id, 116 | name: name.clone().into_owned(), 117 | ..Default::default() 118 | }) 119 | }, 120 | ), 121 | super::Instruction::RemoveText => Self::find_and_mutate(element, el_id, |el| { 122 | el.text = None; 123 | }), 124 | super::Instruction::RemoveListeners => { 125 | Self::find_and_mutate(element, el_id, |el| { 126 | el.listeners = vec![]; 127 | }) 128 | } 129 | super::Instruction::AttachListener { name, on } => { 130 | Self::find_and_mutate(element, el_id, |el| { 131 | el.listeners.push(ListenerRef::new(name, on)) 132 | }) 133 | } 134 | super::Instruction::SetInnerHtml { 135 | element: _, 136 | html: _, 137 | } => { 138 | panic!("this method should not be called from within this test") 139 | } 140 | super::Instruction::RemoveAttribute { key } => { 141 | Self::find_and_mutate(element, el_id, |el| { 142 | el.attributes 143 | .remove(&Self::crudely_remove_cow_lifetime_problems(key)); 144 | }) 145 | } 146 | super::Instruction::DeleteEl => { 147 | let id = Self::find_parent_id(element, el_id).unwrap(); 148 | Self::find_and_mutate(element, id, |el| { 149 | let pos = el 150 | .children 151 | .iter() 152 | .position(|each| each.id().parse::().unwrap() == el_id) 153 | .unwrap(); 154 | el.children.remove(pos); 155 | }) 156 | } 157 | } 158 | } 159 | } 160 | 161 | fn find_parent_id(el: &Element, id: usize) -> Option { 162 | for child in &el.children { 163 | if child.id().parse::().unwrap() == id { 164 | return Some(el.id().parse::().unwrap()); 165 | } else { 166 | if let Some(id) = Self::find_parent_id(&child, id) { 167 | return Some(id); 168 | } 169 | } 170 | } 171 | None 172 | } 173 | 174 | fn crudely_remove_cow_lifetime_problems(cow: &Cow<'_, Cow<'_, str>>) -> Cow<'static, str> { 175 | cow.clone().into_owned().into_owned().into() 176 | } 177 | 178 | fn find_and_mutate(element: &mut Element, id: usize, mutate: impl FnOnce(&mut Element)) { 179 | let el = Self::find_el_with_id(id, element).expect("failed to find element to mutate"); 180 | (mutate)(el) 181 | } 182 | 183 | fn try_parse(id: impl AsRef) -> Result { 184 | id.as_ref().parse::() 185 | } 186 | 187 | fn parse(id: impl AsRef) -> usize { 188 | Self::try_parse(id.as_ref()).expect(&format!("could not parse the id {:#?}", id.as_ref())) 189 | } 190 | 191 | /// Conducts a depth-first search through the tree for the element. 192 | fn find_el_with_id(id: usize, element: &mut Element) -> Option<&mut Element> { 193 | if id == element.id { 194 | return Some(element); 195 | } 196 | for each in element.children.iter_mut() { 197 | let el = Self::find_el_with_id(id.clone(), each); 198 | if el.is_some() { 199 | return el; 200 | } 201 | } 202 | None 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/element/diff/changeset/instruction_serializer.rs: -------------------------------------------------------------------------------- 1 | use serde::{ 2 | ser::{SerializeSeq, SerializeStruct}, 3 | Serialize, 4 | }; 5 | 6 | use super::{Changeset, Instruction, Op}; 7 | 8 | /// Serializes instructions from a [super::Changeset] in a way that is easier to parse on the JS 9 | /// side than what would be optained using the serde derive macros. 10 | pub(crate) struct InstructionSerializer<'a>(pub(crate) Changeset<'a>); 11 | 12 | impl<'a> Serialize for InstructionSerializer<'a> { 13 | fn serialize(&self, s: S) -> Result 14 | where 15 | S: serde::Serializer, 16 | { 17 | let mut seq = s.serialize_seq(Some(self.0.ops.len()))?; 18 | for op in self.0.ops.iter() { 19 | seq.serialize_element(&OpSerializer(op))?; 20 | } 21 | seq.end() 22 | } 23 | } 24 | 25 | struct OpSerializer<'a, 'b>(&'b Op<'a>); 26 | 27 | impl<'a, 'b> Serialize for OpSerializer<'a, 'b> { 28 | // todo: use acronyms for types of operation to make size smaller 29 | fn serialize(&self, serializer: S) -> Result 30 | where 31 | S: serde::Serializer, 32 | { 33 | let blank_string = &"".to_string(); 34 | let mut struct_serializer = serializer.serialize_struct("Instruction", 3)?; 35 | struct_serializer.serialize_field("el", &self.0.id)?; 36 | match &self.0.instruction { 37 | Instruction::InsertChild { new_child_id } => { 38 | struct_serializer.serialize_field("ty", "insertChild")?; 39 | struct_serializer.serialize_field("payload", &new_child_id)?; 40 | } 41 | Instruction::InsertAfter { after_id } => { 42 | struct_serializer.serialize_field("ty", "insertAfter")?; 43 | struct_serializer.serialize_field("payload", &after_id)?; 44 | } 45 | Instruction::SetAttribute { key, value } => { 46 | struct_serializer.serialize_field("ty", "setAttr")?; 47 | struct_serializer.serialize_field( 48 | "payload", 49 | &format!("{key}+{value}", key = key, value = value), 50 | )?; 51 | } 52 | Instruction::SetId { value } => { 53 | struct_serializer.serialize_field("ty", "setId")?; 54 | struct_serializer.serialize_field("payload", &value)?; 55 | } 56 | Instruction::SetText { value } => { 57 | struct_serializer.serialize_field("ty", "setText")?; 58 | struct_serializer.serialize_field("payload", &value)?; 59 | } 60 | Instruction::SetTagName { name } => { 61 | struct_serializer.serialize_field("ty", "setTagName")?; 62 | struct_serializer.serialize_field("payload", &name)?; 63 | } 64 | Instruction::CreateTag { name, parent_id } => { 65 | struct_serializer.serialize_field("ty", "createTag")?; 66 | struct_serializer.serialize_field( 67 | "payload", 68 | &format!( 69 | "{}+{}", 70 | name, 71 | if let Some(p) = parent_id { 72 | p 73 | } else { 74 | blank_string 75 | } 76 | ), 77 | )?; 78 | } 79 | Instruction::RemoveText => { 80 | struct_serializer.serialize_field("ty", "removeText")?; 81 | struct_serializer.skip_field("payload")?; 82 | } 83 | Instruction::RemoveListeners => { 84 | struct_serializer.serialize_field("ty", "removeListeners")?; 85 | struct_serializer.skip_field("payload")?; 86 | } 87 | Instruction::AttachListener { name, on } => { 88 | struct_serializer.serialize_field("ty", "attachListener")?; 89 | struct_serializer.serialize_field("payload", &format!("{}+{}", name, on))?; 90 | } 91 | Instruction::SetInnerHtml { element, html } => { 92 | struct_serializer.serialize_field("ty", "setInnerHtml")?; 93 | struct_serializer.serialize_field("payload", &format!("{}+{}", element, html))?; 94 | } 95 | Instruction::InsertBefore { before_id } => { 96 | struct_serializer.serialize_field("ty", "insertBefore")?; 97 | struct_serializer.serialize_field("payload", &before_id)?; 98 | } 99 | Instruction::RemoveAttribute { key } => { 100 | struct_serializer.serialize_field("ty", "removeAttr")?; 101 | struct_serializer.serialize_field("payload", key)?; 102 | } 103 | Instruction::DeleteEl => { 104 | struct_serializer.serialize_field("ty", "deleteEl")?; 105 | } 106 | } 107 | struct_serializer.end() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/element/diff/changeset/mod.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[cfg(feature = "apply")] 6 | pub mod apply; 7 | 8 | /// Javascript-friendly instruction serializer. 9 | pub(crate) mod instruction_serializer; 10 | 11 | /// A list of operations to apply to the DOM. Note that the order in which the operations are 12 | /// applied to each element is significant. 13 | #[derive(Debug, Clone, Serialize)] 14 | pub struct Changeset<'a> { 15 | /// A DOM operation. This can be relied on to apply the operations sequentially. 16 | pub(crate) ops: Vec>, 17 | } 18 | 19 | impl<'a> Changeset<'a> { 20 | pub(crate) fn empty() -> Self { 21 | Self { ops: Vec::new() } 22 | } 23 | pub(crate) fn from_op(op: Op<'a>) -> Changeset<'a> { 24 | Self { ops: vec![op] } 25 | } 26 | pub(crate) fn extend(&mut self, other: Changeset<'a>) { 27 | self.ops.extend(other.ops); 28 | } 29 | } 30 | 31 | impl<'a> IntoIterator for Changeset<'a> { 32 | type Item = Op<'a>; 33 | 34 | type IntoIter = std::vec::IntoIter; 35 | 36 | fn into_iter(self) -> Self::IntoIter { 37 | self.ops.into_iter() 38 | } 39 | } 40 | 41 | #[derive(Debug, Clone, Serialize)] 42 | pub struct Op<'a> { 43 | pub(crate) id: String, 44 | pub(crate) instruction: Instruction<'a>, 45 | } 46 | 47 | /// An instruction to update the DOM 48 | #[derive(Debug, Clone, Serialize)] 49 | pub(crate) enum Instruction<'a> { 50 | #[allow(unused)] 51 | InsertChild { 52 | new_child_id: String, 53 | }, 54 | InsertAfter { 55 | after_id: String, 56 | }, 57 | InsertBefore { 58 | before_id: String, 59 | }, 60 | SetAttribute { 61 | key: Cow<'a, Cow<'static, str>>, 62 | value: Cow<'a, Cow<'static, str>>, 63 | }, 64 | RemoveAttribute { 65 | key: Cow<'a, Cow<'static, str>>, 66 | }, 67 | SetId { 68 | value: String, 69 | }, 70 | SetText { 71 | value: Cow<'a, Cow<'a, str>>, 72 | }, 73 | SetTagName { 74 | name: Cow<'a, Cow<'static, str>>, 75 | }, 76 | CreateTag { 77 | name: Cow<'a, Cow<'static, str>>, 78 | parent_id: Option, 79 | }, 80 | RemoveText, 81 | RemoveListeners, 82 | AttachListener { 83 | name: String, 84 | on: String, 85 | }, 86 | #[allow(unused)] 87 | SetInnerHtml { 88 | element: &'static str, 89 | html: String, 90 | }, 91 | DeleteEl, 92 | } 93 | 94 | #[derive(Debug, Deserialize)] 95 | /// An owned analogue of `Changeset` which can be deserialized. 96 | /// 97 | /// Mostly useful for testing. 98 | pub struct DeserializeChangeset { 99 | pub ops: Vec, 100 | } 101 | 102 | #[derive(Debug, Deserialize)] 103 | /// An owned analogue of `Op` which can be deserialized. 104 | /// 105 | /// Mostly useful for testing. 106 | #[allow(dead_code)] 107 | pub struct DeserializeOp { 108 | id: String, 109 | instruction: DeserializeInstruction, 110 | } 111 | 112 | #[derive(Debug, Deserialize)] 113 | /// An owned analogue of `Instruction` which can be deserialized. 114 | /// 115 | /// Mostly useful for testing. 116 | pub enum DeserializeInstruction { 117 | InsertChild { new_child_id: String }, 118 | InsertAfter { after_id: String }, 119 | SetAttribute { key: String, value: String }, 120 | SetId { value: String }, 121 | SetText { value: String }, 122 | SetTagName { name: String }, 123 | CreateTag { name: String, parent_id: String }, 124 | RemoveText, 125 | RemoveListeners, 126 | AttachListener { name: String, on: String }, 127 | SetInnerHtml { element: String, html: String }, 128 | DeleteEl, 129 | } 130 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/element/diff/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod changeset; 2 | 3 | #[cfg(all(test, feature = "apply"))] 4 | #[allow(unused_parens)] 5 | mod test_diffing; 6 | 7 | use std::borrow::Cow; 8 | 9 | use self::changeset::{Changeset, Instruction, Op}; 10 | 11 | use super::Element; 12 | 13 | impl Element { 14 | /// Compare two root nodes and produce a set of instructions which can be followed to produce 15 | /// one from the other. 16 | pub fn diff<'a>(&'a self, other: Option<&'a Element>) -> Changeset<'a> { 17 | let mut c = Changeset::empty(); 18 | 19 | if let Some(other) = other { 20 | // we need to apply all these updates... 21 | c.ops.extend(self.diff_name(other)); 22 | c.ops.extend(self.diff_attributes(other)); 23 | c.ops.extend(self.diff_text(other)); 24 | c.ops.extend(self.diff_listeners(other)); 25 | // ... before we change the id 26 | c.ops.extend(self.diff_children(other)); 27 | c.ops.extend(self.diff_id(other)); 28 | } else { 29 | c.ops.extend(self.create_from_scratch(None)) 30 | } 31 | 32 | c 33 | } 34 | 35 | /// Create a new element from scratch – generally used for the first application paint. 36 | /// 37 | /// In the future we should just send this as a serialized HTML string, rather than a series of 38 | /// Javascript instructions. 39 | fn create_from_scratch(&self, parent: Option<&Element>) -> Changeset { 40 | let mut cs = Changeset::empty(); 41 | 42 | cs.ops.push(Op { 43 | id: self.id(), 44 | instruction: Instruction::CreateTag { 45 | name: Cow::Borrowed(&self.name), 46 | parent_id: parent.map(|parent| parent.id()), 47 | }, 48 | }); 49 | 50 | for (key, value) in self.attributes.iter() { 51 | cs.ops.push(Op { 52 | id: self.id(), 53 | instruction: Instruction::SetAttribute { 54 | key: Cow::Borrowed(key), 55 | value: Cow::Borrowed(value), 56 | }, 57 | }) 58 | } 59 | 60 | cs.extend(self.generate_listeners()); 61 | 62 | if let Some(ref value) = self.text { 63 | cs.ops.push(Op { 64 | id: self.id(), 65 | instruction: Instruction::SetText { 66 | value: Cow::Borrowed(value), 67 | }, 68 | }) 69 | } 70 | 71 | for child in self.children.iter() { 72 | cs.extend(child.create_from_scratch(Some(self))); 73 | } 74 | 75 | cs 76 | } 77 | 78 | /// Compares the text of two nodes, and emits the relevant update needed to make sure that the 79 | /// text is the same. 80 | fn diff_text<'a>(&'a self, other: &'a Element) -> Changeset<'a> { 81 | let mut cs = Changeset::empty(); 82 | 83 | if self.text != other.text { 84 | match other.text { 85 | Some(ref value) => cs.ops.push(Op { 86 | id: self.id(), 87 | instruction: Instruction::SetText { 88 | value: Cow::Borrowed(value), 89 | }, 90 | }), 91 | None => cs.ops.push(Op { 92 | id: self.id(), 93 | instruction: Instruction::RemoveText, 94 | }), 95 | } 96 | } 97 | 98 | cs 99 | } 100 | 101 | /// Compares the ID's of two elements and emits instructions to update the underlying DOM node 102 | /// so that it has the new ID. 103 | fn diff_id<'a>(&'a self, other: &'a Element) -> Changeset { 104 | if self.id != other.id { 105 | Changeset::from_op(Op { 106 | id: self.id(), 107 | instruction: Instruction::SetId { value: other.id() }, 108 | }) 109 | } else { 110 | Changeset::empty() 111 | } 112 | } 113 | 114 | /// Compares the tag names of two DOM nodes and emits instructions to update the underlying DOM 115 | /// so that the old tag takes on the name of the new tag. 116 | fn diff_name<'a>(&'a self, other: &'a Element) -> Changeset<'a> { 117 | if self.name != other.name { 118 | Changeset::from_op(Op { 119 | id: other.id(), 120 | instruction: Instruction::SetTagName { 121 | name: Cow::Borrowed(&other.name), 122 | }, 123 | }) 124 | } else { 125 | Changeset::empty() 126 | } 127 | } 128 | 129 | /// Compares the attributes of two DOM nodes and emits instructions to update the old DOM node 130 | /// to take on the attributes of the new DOM node. 131 | fn diff_attributes<'a>(&'a self, other: &'a Element) -> Changeset<'a> { 132 | let mut c = Changeset::empty(); 133 | 134 | if self.attributes == other.attributes { 135 | return Changeset::empty(); 136 | } 137 | 138 | for key in self.attributes.keys() { 139 | if !other.attributes.contains_key(key) { 140 | c.ops.push(Op { 141 | id: self.id(), 142 | instruction: Instruction::RemoveAttribute { 143 | key: Cow::Borrowed(key), 144 | }, 145 | }) 146 | } 147 | } 148 | 149 | for (their_key, their_value) in other.attributes.iter() { 150 | let my_value = self.attributes.get(their_key); 151 | 152 | if let Some(my_value) = my_value { 153 | if my_value != their_value { 154 | c.ops.push(Op { 155 | id: self.id(), 156 | instruction: Instruction::SetAttribute { 157 | key: Cow::Borrowed(their_key), 158 | value: Cow::Borrowed(their_value), 159 | }, 160 | }) 161 | } 162 | } else { 163 | c.ops.push(Op { 164 | id: self.id(), 165 | instruction: Instruction::SetAttribute { 166 | key: Cow::Borrowed(their_key), 167 | value: Cow::Borrowed(their_value), 168 | }, 169 | }) 170 | } 171 | } 172 | 173 | c 174 | } 175 | 176 | /// Compare the listeners attached to this element, and the other element, and emit instructions 177 | /// to modify them as needed. 178 | fn diff_listeners<'a>(&'a self, other: &'a Element) -> Changeset<'a> { 179 | let mut c = Changeset::empty(); 180 | 181 | if self.listeners == other.listeners { 182 | } else { 183 | c.ops.push(Op { 184 | id: self.id(), 185 | instruction: Instruction::RemoveListeners, 186 | }); 187 | c.ops.extend(other.generate_listeners()) 188 | } 189 | 190 | c 191 | } 192 | 193 | /// Generates all the listeners which should be attached to a given `Element`. 194 | fn generate_listeners(&'_ self) -> Changeset<'_> { 195 | let mut c = Changeset::empty(); 196 | 197 | for listener in self.listeners.iter() { 198 | c.ops.push(Op { 199 | id: self.id(), 200 | instruction: Instruction::AttachListener { 201 | name: listener.listener_name().to_string(), 202 | on: listener.js_event().to_string(), 203 | }, 204 | }) 205 | } 206 | 207 | c 208 | } 209 | 210 | /// Tries to find a child with the provided key. Returns `None` if a child cannot be found. 211 | fn locate_child_by_key(&self, key: &str) -> Option<&Element> { 212 | self.children 213 | .iter() 214 | .find(|el| el.key.as_ref().unwrap().eq(key)) 215 | } 216 | 217 | /// Compares the children of two nodes and emits instructions to update the old children to 218 | /// assume the form of the new children. 219 | fn diff_children<'a>(&'a self, other: &'a Element) -> Changeset<'a> { 220 | let mut changeset = Changeset::empty(); 221 | 222 | if self.children_are_all_keyed() && other.children_are_all_keyed() { 223 | // nodes which are shared between us and the other node 224 | let mut unpaired = vec![]; 225 | for (i, their_child) in other.children.iter().enumerate() { 226 | if let Some(my_child) = self.locate_child_by_key(their_child.key.as_ref().unwrap()) 227 | { 228 | changeset.ops.extend(my_child.diff(Some(their_child))); 229 | } else { 230 | unpaired.push(i); 231 | } 232 | } 233 | 234 | // generate all the new nodes (the ones that exist for them, but not for us) 235 | let len = unpaired.len(); 236 | for each in unpaired { 237 | changeset.ops.push(Op { 238 | id: other.children[each].id(), 239 | instruction: if each == 0 { 240 | Instruction::InsertBefore { 241 | before_id: other.children[1].id(), 242 | } 243 | } else if each == len { 244 | Instruction::InsertAfter { 245 | after_id: other.children[each].id(), 246 | } 247 | } else { 248 | Instruction::InsertBefore { 249 | before_id: other.children[each - 1].id(), 250 | } 251 | }, 252 | }) 253 | } 254 | 255 | // delete all the nodes that we have that they don't 256 | for our_child in self.children.iter() { 257 | if other 258 | .locate_child_by_key(our_child.key.as_ref().unwrap()) 259 | .is_none() 260 | { 261 | changeset.ops.push(Op { 262 | id: our_child.id(), 263 | instruction: Instruction::DeleteEl, 264 | }) 265 | } 266 | } 267 | } else { 268 | let self_len = self.children.len(); 269 | let other_len = other.children.len(); 270 | 271 | #[allow(clippy::comparison_chain)] 272 | if self_len == other_len { 273 | for (my_child, their_child) in self.children.iter().zip(&other.children) { 274 | changeset.ops.extend(my_child.diff(Some(their_child))); 275 | } 276 | // we are done 277 | } else if self_len > other_len { 278 | for el in self.children[other_len..].iter() { 279 | changeset.ops.push(Op { 280 | id: el.id(), 281 | instruction: Instruction::DeleteEl, 282 | }) 283 | } 284 | for (my_child, their_child) in self.children.iter().zip(&other.children) { 285 | changeset.ops.extend(my_child.diff(Some(their_child))); 286 | } 287 | } else { 288 | for (my_child, their_child) in self.children.iter().zip(&other.children) { 289 | changeset.ops.extend(my_child.diff(Some(their_child))); 290 | } 291 | // add all the elements that they have, but we don't 292 | for el in other.children[self_len..].iter() { 293 | changeset.ops.extend(el.create_from_scratch(Some(self))); 294 | } 295 | } 296 | } 297 | 298 | changeset 299 | } 300 | 301 | /// Checks that the `Element` doesn't have children without a key. 302 | fn children_are_all_keyed(&self) -> bool { 303 | self.children.iter().all(|el| el.key.is_some()) 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/element/diff/test_diffing.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::dom::{element::Element, listener::ListenerRef}; 4 | 5 | use super::changeset::apply; 6 | 7 | #[lunatic::test] 8 | fn test_name_change() { 9 | let mut old = Element { 10 | id: 0, 11 | name: "div".into(), 12 | ..Default::default() 13 | }; 14 | 15 | let new = Element { 16 | id: 0, 17 | name: "p".into(), 18 | ..Default::default() 19 | }; 20 | 21 | let old2 = old.clone(); 22 | let cs = old2.diff(Some(&new)); 23 | 24 | cs.apply(&mut old); 25 | 26 | assert_eq!(old, new); 27 | } 28 | 29 | #[lunatic::test] 30 | fn test_single_element_attribute_change() { 31 | let old = Element { 32 | id: 0, 33 | name: "div".into(), 34 | attributes: { 35 | let mut res = HashMap::new(); 36 | res.insert("class".into(), "one".into()); 37 | res.insert( 38 | "attribute-which-doesn-t-exist-after-diffing".into(), 39 | "1".into(), 40 | ); 41 | res 42 | }, 43 | ..Default::default() 44 | }; 45 | 46 | let new = Element { 47 | id: 0, 48 | name: "div".into(), 49 | attributes: { 50 | let mut res = HashMap::new(); 51 | res.insert("class".into(), "two".into()); 52 | res.insert("new-attribute-added-after-diffing".into(), "value".into()); 53 | res 54 | }, 55 | ..Default::default() 56 | }; 57 | 58 | let cs = old.diff(Some(&new)); 59 | 60 | assert_eq!(cs.ops.len(), 3); 61 | assert!(cs.ops.iter().any(|op| { 62 | match &op.instruction { 63 | crate::dom::element::diff::changeset::Instruction::SetAttribute { key, value } => { 64 | key.to_string() == "new-attribute-added-after-diffing".to_string() 65 | && value.to_string() == "value".to_string() 66 | } 67 | _ => false, 68 | } 69 | })); 70 | assert!(cs.ops.iter().any(|op| { 71 | match &op.instruction { 72 | crate::dom::element::diff::changeset::Instruction::SetAttribute { key, value } => { 73 | key.to_string() == "class".to_string() && value.to_string() == "two".to_string() 74 | } 75 | _ => false, 76 | } 77 | })); 78 | } 79 | 80 | #[lunatic::test] 81 | fn test_single_element_text_change() { 82 | let mut old = Element { 83 | id: 0, 84 | name: "p".into(), 85 | text: Some("the cat sat on the mat".into()), 86 | ..Default::default() 87 | }; 88 | 89 | let new = Element { 90 | id: 0, 91 | name: "p".into(), 92 | text: Some("the mat sat on the cat".into()), 93 | ..Default::default() 94 | }; 95 | 96 | let old2 = old.clone(); 97 | let cs = old2.diff(Some(&new)); 98 | 99 | cs.apply(&mut old); 100 | 101 | assert_eq!(old, new); 102 | } 103 | 104 | #[lunatic::test] 105 | #[ignore = "todo: fix"] 106 | fn test_add_child_change() { 107 | let mut old = Element { 108 | id: 0, 109 | name: "div".into(), 110 | children: vec![Element { 111 | id: 3, 112 | key: Some("a".into()), 113 | name: "p".into(), 114 | text: Some("the cat sat on the mat".into()), 115 | ..Default::default() 116 | }], 117 | ..Default::default() 118 | }; 119 | 120 | let new = Element { 121 | id: 0, 122 | name: "div".into(), 123 | children: vec![ 124 | Element { 125 | id: 3, 126 | name: "p".into(), 127 | key: Some("a".into()), 128 | text: Some("the cat sat on the mat".into()), 129 | ..Default::default() 130 | }, 131 | Element { 132 | id: 2, 133 | name: "p".into(), 134 | key: Some("b".into()), 135 | text: Some("the mat sat on the cat".into()), 136 | ..Default::default() 137 | }, 138 | ], 139 | ..Default::default() 140 | }; 141 | 142 | let old2 = old.clone(); 143 | let cs = old2.diff(Some(&new)); 144 | 145 | cs.apply(&mut old); 146 | assert_eq!(old, new); 147 | } 148 | 149 | #[lunatic::test] 150 | #[ignore = "todo: fix"] 151 | fn test_add_child_before_change() { 152 | let mut old = Element { 153 | id: 0, 154 | name: "div".into(), 155 | children: vec![Element { 156 | id: 3, 157 | key: Some("a".into()), 158 | name: "p".into(), 159 | text: Some("the cat sat on the mat".into()), 160 | ..Default::default() 161 | }], 162 | ..Default::default() 163 | }; 164 | 165 | let new = Element { 166 | id: 0, 167 | name: "div".into(), 168 | children: vec![ 169 | Element { 170 | id: 3, 171 | name: "p".into(), 172 | key: Some("b".into()), 173 | text: Some("the mat sat on the cat".into()), 174 | ..Default::default() 175 | }, 176 | Element { 177 | id: 2, 178 | name: "p".into(), 179 | key: Some("a".into()), 180 | text: Some("the cat sat on the mat".into()), 181 | ..Default::default() 182 | }, 183 | ], 184 | ..Default::default() 185 | }; 186 | 187 | let old2 = old.clone(); 188 | let cs = old2.diff(Some(&new)); 189 | 190 | cs.apply(&mut old); 191 | assert_eq!(old, new); 192 | } 193 | 194 | #[lunatic::test] 195 | fn test_more_complex_diff() { 196 | let mut old = Element { 197 | id: 0, 198 | name: std::borrow::Cow::Borrowed("div"), 199 | attributes: { 200 | let _cap = <[()]>::len(&[()]); 201 | let mut _map = ::std::collections::HashMap::with_capacity(_cap); 202 | let _ = _map.insert(("class".into()), ("message-list".into())); 203 | _map 204 | }, 205 | listeners: vec![], 206 | children: vec![ 207 | Element { 208 | id: 3, 209 | name: std::borrow::Cow::Borrowed("div"), 210 | attributes: HashMap::new(), 211 | listeners: vec![], 212 | children: vec![Element { 213 | id: 4, 214 | name: std::borrow::Cow::Borrowed("input"), 215 | attributes: HashMap::new(), 216 | listeners: vec![ListenerRef::new("msg-input", "input")], 217 | children: vec![], 218 | text: None, 219 | key: None, 220 | }], 221 | text: None, 222 | key: None, 223 | }, 224 | Element { 225 | id: 2, 226 | name: std::borrow::Cow::Borrowed("div"), 227 | attributes: HashMap::new(), 228 | listeners: vec![], 229 | children: vec![Element { 230 | id: 5, 231 | name: std::borrow::Cow::Borrowed("button"), 232 | attributes: HashMap::new(), 233 | listeners: vec![ListenerRef::new("msg-submit", "click")], 234 | children: vec![], 235 | text: Some(std::borrow::Cow::Borrowed("Send message")), 236 | key: None, 237 | }], 238 | text: None, 239 | key: None, 240 | }, 241 | ], 242 | text: None, 243 | key: None, 244 | }; 245 | let new = Element { 246 | id: 0, 247 | name: std::borrow::Cow::Borrowed("div"), 248 | attributes: { 249 | let _cap = <[()]>::len(&[()]); 250 | let mut _map = ::std::collections::HashMap::with_capacity(_cap); 251 | let _ = _map.insert(("class".into()), ("message-list".into())); 252 | _map 253 | }, 254 | listeners: vec![], 255 | children: vec![ 256 | Element { 257 | id: 3, 258 | name: std::borrow::Cow::Borrowed("div"), 259 | attributes: HashMap::new(), 260 | listeners: vec![], 261 | children: vec![Element { 262 | id: 4, 263 | name: std::borrow::Cow::Borrowed("input"), 264 | attributes: HashMap::new(), 265 | listeners: vec![ListenerRef::new( 266 | "msg-input".to_string(), 267 | "input".to_string(), 268 | )], 269 | children: vec![], 270 | text: None, 271 | key: None, 272 | }], 273 | text: None, 274 | key: None, 275 | }, 276 | Element { 277 | id: 2, 278 | name: std::borrow::Cow::Borrowed("div"), 279 | attributes: HashMap::new(), 280 | listeners: vec![], 281 | children: vec![Element { 282 | id: 5, 283 | name: std::borrow::Cow::Borrowed("button"), 284 | attributes: HashMap::new(), 285 | listeners: vec![ListenerRef::new( 286 | "msg-submit".to_string(), 287 | "click".to_string(), 288 | )], 289 | children: vec![], 290 | text: Some(std::borrow::Cow::Borrowed("Send message")), 291 | key: None, 292 | }], 293 | text: None, 294 | key: None, 295 | }, 296 | Element { 297 | id: 9, 298 | name: std::borrow::Cow::Borrowed("div"), 299 | attributes: { 300 | let _cap = <[()]>::len(&[()]); 301 | let mut _map = ::std::collections::HashMap::with_capacity(_cap); 302 | let _ = _map.insert(("class".into()), ("message-container".into())); 303 | _map 304 | }, 305 | listeners: vec![], 306 | children: vec![ 307 | Element { 308 | id: 8, 309 | name: std::borrow::Cow::Borrowed("p"), 310 | attributes: { 311 | let _cap = <[()]>::len(&[()]); 312 | let mut _map = ::std::collections::HashMap::with_capacity(_cap); 313 | let _ = _map.insert(("class".into()), ("message-sent-at".into())); 314 | _map 315 | }, 316 | listeners: vec![], 317 | children: vec![], 318 | text: Some(std::borrow::Cow::Borrowed("1970-01-25 06:34:13")), 319 | key: None, 320 | }, 321 | Element { 322 | id: 6, 323 | name: std::borrow::Cow::Borrowed("p"), 324 | attributes: { 325 | let _cap = <[()]>::len(&[()]); 326 | let mut _map = ::std::collections::HashMap::with_capacity(_cap); 327 | let _ = _map.insert(("class".into()), ("message-author".into())); 328 | _map 329 | }, 330 | listeners: vec![], 331 | children: vec![], 332 | text: Some(std::borrow::Cow::Borrowed("[username not set]")), 333 | key: None, 334 | }, 335 | Element { 336 | id: 7, 337 | name: std::borrow::Cow::Borrowed("p"), 338 | attributes: { 339 | let _cap = <[()]>::len(&[()]); 340 | let mut _map = ::std::collections::HashMap::with_capacity(_cap); 341 | let _ = _map.insert(("class".into()), ("message-contents".into())); 342 | _map 343 | }, 344 | listeners: vec![], 345 | children: vec![], 346 | text: Some(std::borrow::Cow::Borrowed("sending message")), 347 | key: None, 348 | }, 349 | ], 350 | text: None, 351 | key: None, 352 | }, 353 | ], 354 | text: None, 355 | key: None, 356 | }; 357 | 358 | let old2 = old.clone(); 359 | let cs = old2.diff(Some(&new)); 360 | 361 | cs.apply(&mut old); 362 | 363 | assert_eq!(old, new); 364 | } 365 | 366 | #[lunatic::test] 367 | fn test_delete_child_change() {} 368 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/element/mod.rs: -------------------------------------------------------------------------------- 1 | //! Create HTML elements. Unfortunately at the moment this is the API – in the future we'll provide 2 | //! something a bit nicer to work with. 3 | 4 | use std::{borrow::Cow, collections::HashMap}; 5 | 6 | use super::listener::ListenerRef; 7 | 8 | pub mod diff; 9 | pub mod render; 10 | 11 | /// An HTML element. 12 | #[derive(Builder, Clone, Default, Debug, Eq, PartialEq)] 13 | pub struct Element { 14 | /// The id includes the ID of this element and all the parent elements. 15 | pub id: usize, 16 | #[builder(setter(into))] 17 | pub name: Cow<'static, str>, 18 | pub attributes: HashMap, Cow<'static, str>>, 19 | pub listeners: Vec, 20 | pub children: Vec, 21 | pub text: Option>, 22 | pub key: Option, 23 | } 24 | 25 | impl Element { 26 | /// Return the unique ID of this element. 27 | fn id(&self) -> String { 28 | self.id.to_string() 29 | } 30 | 31 | /// This function returns a builder type for this struct. 32 | pub fn build() -> ElementBuilder { 33 | ElementBuilder::default() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/element/orchestrator.rs: -------------------------------------------------------------------------------- 1 | // todo: fix this 2 | 3 | use std::{collections::HashMap, mem}; 4 | 5 | use lunatic::{ 6 | channel::{Receiver, Sender}, 7 | net::TcpStream, 8 | Process, 9 | }; 10 | 11 | use puck::ws::{message::Message, websocket::WebSocket}; 12 | use serde::{Deserialize, Serialize}; 13 | 14 | use crate::{ 15 | client::ClientMessage, 16 | dom::{ 17 | event::{ClickEvent, InputEvent, SubmitEvent}, 18 | listener::Listener, 19 | }, 20 | }; 21 | 22 | use super::{diff::changeset::instruction_serializer::JsFriendlyInstructionSerializer, Element}; 23 | 24 | pub trait Component { 25 | fn new(data: DATA) -> Self; 26 | 27 | fn update(&mut self, input: INPUT); 28 | 29 | fn render(&self) -> (Element, HashMap>); 30 | } 31 | 32 | pub fn manage, DATA: 'static, INPUT>( 33 | stream: TcpStream, 34 | (oh_server_fill_me_with_messages, give_me_messages): ( 35 | Sender>, 36 | Receiver>, 37 | ), 38 | start_data: DATA, 39 | ) where 40 | INPUT: Serialize + for<'de> Deserialize<'de>, 41 | DATA: Clone, 42 | { 43 | Process::spawn_with( 44 | (stream.clone(), oh_server_fill_me_with_messages), 45 | |(stream, oh_server_fill_me_with_messages)| loop { 46 | let next = Message::next(stream.clone()); 47 | match next { 48 | Ok(msg) => oh_server_fill_me_with_messages 49 | .send(MessageWrapper::WebSocketMessage(msg)) 50 | .unwrap(), 51 | Err(_) => { 52 | break; 53 | } 54 | } 55 | }, 56 | ) 57 | .detach(); 58 | 59 | let mut component = C::new(start_data); 60 | 61 | let (mut old_dom, mut old_listeners) = component.render(); 62 | 63 | let instructions = old_dom.diff(None); 64 | 65 | let payload = serde_json::to_string(&JsFriendlyInstructionSerializer(instructions)).unwrap(); 66 | 67 | WebSocket::send_to_stream(stream.clone(), Message::Text(payload)).unwrap(); 68 | 69 | while let Ok(input) = give_me_messages.receive() { 70 | match input { 71 | MessageWrapper::WebSocketMessage(msg) => { 72 | if let Message::Text(contents) = msg { 73 | if let Ok(t) = serde_json::from_str::(&contents) { 74 | if let Some(listener) = old_listeners.get(&t.listener) { 75 | match listener { 76 | Listener::Click { call } => { 77 | let input = (call)(ClickEvent); 78 | component.update(input); 79 | } 80 | Listener::Submit { call } => { 81 | let input = (call)(SubmitEvent); 82 | component.update(input); 83 | } 84 | Listener::Input { call } => { 85 | if let Some(payload) = t.payload { 86 | let input = (call)(InputEvent { 87 | value: payload.value, 88 | }); 89 | component.update(input); 90 | } 91 | } 92 | } 93 | } 94 | } else { 95 | { 96 | continue; 97 | } 98 | }; 99 | } 100 | } 101 | 102 | MessageWrapper::WrappedMessageToPassOnToClient(input) => { 103 | component.update(input); 104 | 105 | let (mut new_dom, mut new_listeners) = component.render(); 106 | 107 | let instructions = old_dom.diff(Some(&new_dom)); 108 | 109 | WebSocket::send_to_stream( 110 | stream.clone(), 111 | Message::Text( 112 | serde_json::to_string(&JsFriendlyInstructionSerializer(instructions)) 113 | .unwrap(), 114 | ), 115 | ) 116 | .unwrap(); 117 | 118 | mem::swap(&mut old_dom, &mut new_dom); 119 | mem::swap(&mut old_listeners, &mut new_listeners); 120 | } 121 | } 122 | } 123 | } 124 | 125 | #[derive(Serialize, Deserialize)] 126 | // todo: make this much tidier 127 | pub enum MessageWrapper { 128 | WebSocketMessage(Message), 129 | WrappedMessageToPassOnToClient(WRAP), 130 | } 131 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/element/render.rs: -------------------------------------------------------------------------------- 1 | use super::Element; 2 | 3 | impl Element { 4 | /// Render the `Element` as HTML. This speeds up page load times, because we send this first. 5 | #[allow(unused)] 6 | pub(crate) fn render(&self) -> String { 7 | self.write_opening_tag() 8 | + if let Some(ref text) = self.text { 9 | text 10 | } else { 11 | "" 12 | } 13 | + &self 14 | .children 15 | .iter() 16 | .map(|child| child.render()) 17 | .collect::() 18 | + &self.write_closing_tag() 19 | } 20 | 21 | /// Renders the opening tag as an HTML string. Note that we intentionally do not write the 22 | /// event listeners as HTML; we emit instructions to add them that we then stream over the 23 | /// WebSocket. 24 | fn write_opening_tag(&self) -> String { 25 | "<".to_string() + &self.name + " " + &self.write_attributes() + " " + ">" 26 | } 27 | 28 | /// Returns the closing tag. 29 | fn write_closing_tag(&self) -> String { 30 | "" 31 | } 32 | 33 | /// Writes the attributes for this element. 34 | fn write_attributes(&self) -> String { 35 | self.attributes 36 | .iter() 37 | .map(|(key, value)| "\"".to_string() + key + "\"=" + "\"" + value + "\" ") 38 | .collect::() 39 | } 40 | } 41 | 42 | #[cfg(test)] 43 | mod test_render { 44 | use scraper::{Html, Selector}; 45 | 46 | use crate::dom::element::Element; 47 | 48 | #[lunatic::test] 49 | fn test_render_nested_tags() { 50 | let tree = Element { 51 | id: 0, 52 | name: "div".into(), 53 | children: vec![ 54 | Element { 55 | id: 1, 56 | name: "p".into(), 57 | key: Some("a".to_string()), 58 | text: Some("the cat sat on the mat".into()), 59 | ..Default::default() 60 | }, 61 | Element { 62 | id: 2, 63 | name: "p".into(), 64 | key: Some("b".to_string()), 65 | text: Some("the mat sat on the cat".into()), 66 | ..Default::default() 67 | }, 68 | ], 69 | ..Default::default() 70 | }; 71 | 72 | let html = tree.render(); 73 | let fragment = Html::parse_fragment(&html); 74 | 75 | let div_selector = Selector::parse("div").unwrap(); 76 | let p_selector = Selector::parse("p").unwrap(); 77 | 78 | let p_tags = fragment.select(&p_selector).collect::>(); 79 | 80 | assert_eq!(fragment.select(&div_selector).collect::>().len(), 1); 81 | assert_eq!(p_tags.len(), 2); 82 | 83 | let first_tag = p_tags[0]; 84 | assert_eq!( 85 | first_tag 86 | .first_child() 87 | .unwrap() 88 | .value() 89 | .as_text() 90 | .unwrap() 91 | .to_string(), 92 | "the cat sat on the mat".to_string() 93 | ); 94 | 95 | let second_tag = p_tags[1]; 96 | assert_eq!( 97 | second_tag 98 | .first_child() 99 | .unwrap() 100 | .value() 101 | .as_text() 102 | .unwrap() 103 | .to_string(), 104 | "the mat sat on the cat".to_string() 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/event.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone)] 2 | pub struct ClickEvent; 3 | 4 | #[derive(Clone)] 5 | pub struct SubmitEvent; 6 | 7 | #[derive(Clone)] 8 | pub struct InputEvent { 9 | pub value: String, 10 | } 11 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/listener.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use super::event::{ClickEvent, InputEvent, SubmitEvent}; 4 | 5 | /// A DOM listener. 6 | 7 | pub enum Listener { 8 | Click { 9 | call: Box INPUT>, 10 | }, 11 | Submit { 12 | call: Box INPUT>, 13 | }, 14 | Input { 15 | call: Box INPUT>, 16 | }, 17 | } 18 | 19 | impl Debug for Listener { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | f.debug_struct("Listener").finish() 22 | } 23 | } 24 | 25 | impl From INPUT>> for Listener { 26 | fn from(call: Box INPUT>) -> Self { 27 | Self::Click { call } 28 | } 29 | } 30 | 31 | impl From INPUT>> for Listener { 32 | fn from(call: Box INPUT>) -> Self { 33 | Self::Submit { call } 34 | } 35 | } 36 | 37 | impl From INPUT>> for Listener { 38 | fn from(call: Box INPUT>) -> Self { 39 | Self::Input { call } 40 | } 41 | } 42 | 43 | impl Listener { 44 | #[inline] 45 | pub fn js_event(&self) -> &'static str { 46 | match self { 47 | Listener::Click { call: _ } => "click", 48 | Listener::Submit { call: _ } => "submit", 49 | Listener::Input { call: _ } => "input", 50 | } 51 | } 52 | } 53 | 54 | #[derive(Debug, Clone, PartialEq, Eq)] 55 | pub struct ListenerRef { 56 | pub(crate) listener_name: String, 57 | pub(crate) js_event: String, 58 | } 59 | 60 | impl ListenerRef { 61 | pub fn new(listener_name: impl ToString, js_event: impl ToString) -> Self { 62 | Self { 63 | listener_name: listener_name.to_string(), 64 | js_event: js_event.to_string(), 65 | } 66 | } 67 | /// Get a reference to the listener ref's listener name. 68 | pub fn listener_name(&self) -> &str { 69 | self.listener_name.as_str() 70 | } 71 | 72 | /// Get a reference to the listener ref's js event. 73 | pub fn js_event(&self) -> &str { 74 | self.js_event.as_str() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /puck_liveview/src/dom/mod.rs: -------------------------------------------------------------------------------- 1 | //! The server-side virtual DOM. 2 | 3 | pub mod element; 4 | pub mod event; 5 | pub mod listener; 6 | -------------------------------------------------------------------------------- /puck_liveview/src/html/bigger_tree: -------------------------------------------------------------------------------- 1 | Element { id: 0, name: "div", attributes: {}, listeners: [], children: [Element { id: 1, name: "h1", attributes: {}, listeners: [], children: [], text: Some("Heading 1"), key: None }, Element { id: 2, name: "input", attributes: {"type": "submit"}, listeners: [ListenerRef { listener_name: "a_listener", js_event: "click" }], children: [], text: None, key: None }], text: None, key: None } -------------------------------------------------------------------------------- /puck_liveview/src/html/id.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Default)] 2 | pub struct IdGen { 3 | head: usize, 4 | } 5 | 6 | impl IdGen { 7 | pub fn new() -> IdGen { 8 | Default::default() 9 | } 10 | 11 | pub(crate) fn new_id(&mut self) -> usize { 12 | let ret = self.head; 13 | self.head += 1; 14 | ret 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /puck_liveview/src/html/id_not_starting_from_zero: -------------------------------------------------------------------------------- 1 | Element { id: 1, name: "form", attributes: {}, listeners: [], children: [Element { id: 2, name: "input", attributes: {"type": "text"}, listeners: [], children: [], text: None, key: None }, Element { id: 3, name: "input", attributes: {"type": "submit"}, listeners: [], children: [], text: None, key: None }], text: None, key: None } -------------------------------------------------------------------------------- /puck_liveview/src/html/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use malvolio::prelude::BodyNode; 4 | 5 | use crate::dom::{element::Element, listener::ListenerRef}; 6 | 7 | use self::id::IdGen; 8 | 9 | pub mod id; 10 | 11 | #[derive(Debug)] 12 | #[must_use] 13 | pub struct WrappedBodyNode { 14 | node: BodyNode, 15 | listeners: Vec, 16 | children: Vec, 17 | } 18 | 19 | macro_rules! map_heading_to_element { 20 | ($self:ident, $id:expr, $h:ident) => {{ 21 | let $h = $h.into_pub_fields(); 22 | Element { 23 | id: $id, 24 | name: std::borrow::Cow::Borrowed(stringify!($h)), 25 | attributes: $h.attrs, 26 | listeners: $self.listeners, 27 | // headings currently can't have children; this will be rectified in the future 28 | children: vec![], 29 | text: Some($h.text), 30 | key: None, 31 | } 32 | }}; 33 | } 34 | 35 | impl WrappedBodyNode { 36 | pub fn into_element(self, id_gen: &mut IdGen) -> Element { 37 | match self.node { 38 | BodyNode::H1(h1) => { 39 | map_heading_to_element!(self, id_gen.new_id(), h1) 40 | } 41 | BodyNode::H2(h2) => { 42 | map_heading_to_element!(self, id_gen.new_id(), h2) 43 | } 44 | BodyNode::H3(h3) => { 45 | map_heading_to_element!(self, id_gen.new_id(), h3) 46 | } 47 | BodyNode::H4(h4) => { 48 | map_heading_to_element!(self, id_gen.new_id(), h4) 49 | } 50 | BodyNode::H5(h5) => { 51 | map_heading_to_element!(self, id_gen.new_id(), h5) 52 | } 53 | BodyNode::H6(h6) => { 54 | map_heading_to_element!(self, id_gen.new_id(), h6) 55 | } 56 | BodyNode::P(p) => { 57 | let p = p.into_pub_fields(); 58 | Element { 59 | id: id_gen.new_id(), 60 | name: std::borrow::Cow::Borrowed("p"), 61 | attributes: p.attrs, 62 | listeners: self.listeners, 63 | children: self 64 | .children 65 | .into_iter() 66 | .map(|child| child.into_element(id_gen)) 67 | .collect(), 68 | text: Some(p.text), 69 | key: None, 70 | } 71 | } 72 | BodyNode::Form(form) => { 73 | let form = form.into_pub_fields(); 74 | Element { 75 | id: id_gen.new_id(), 76 | name: std::borrow::Cow::Borrowed("form"), 77 | attributes: form.attrs, 78 | listeners: self.listeners, 79 | children: self 80 | .children 81 | .into_iter() 82 | .map(|child| child.into_element(id_gen)) 83 | .collect(), 84 | text: None, 85 | key: None, 86 | } 87 | } 88 | BodyNode::Br(_) => Element { 89 | id: id_gen.new_id(), 90 | name: std::borrow::Cow::Borrowed("br"), 91 | attributes: HashMap::new(), 92 | listeners: vec![], 93 | children: vec![], 94 | text: None, 95 | key: None, 96 | }, 97 | BodyNode::Div(div) => { 98 | let div = div.into_pub_fields(); 99 | Element { 100 | id: id_gen.new_id(), 101 | name: std::borrow::Cow::Borrowed("div"), 102 | attributes: div.attrs, 103 | listeners: self.listeners, 104 | children: self 105 | .children 106 | .into_iter() 107 | .map(|child| child.into_element(id_gen)) 108 | .collect(), 109 | text: None, 110 | key: None, 111 | } 112 | } 113 | BodyNode::A(a) => { 114 | let a = a.into_pub_fields(); 115 | Element { 116 | id: id_gen.new_id(), 117 | name: std::borrow::Cow::Borrowed("a"), 118 | attributes: a.attrs, 119 | listeners: self.listeners, 120 | children: vec![], 121 | text: Some(a.text), 122 | key: None, 123 | } 124 | } 125 | BodyNode::Input(input) => { 126 | let input = input.into_pub_fields(); 127 | Element { 128 | id: id_gen.new_id(), 129 | name: std::borrow::Cow::Borrowed("input"), 130 | attributes: input.attrs, 131 | listeners: self.listeners, 132 | children: self 133 | .children 134 | .into_iter() 135 | .map(|child| child.into_element(id_gen)) 136 | .collect(), 137 | text: None, 138 | key: None, 139 | } 140 | } 141 | BodyNode::Label(label) => { 142 | map_heading_to_element!(self, id_gen.new_id(), label) 143 | } 144 | BodyNode::Select(select) => { 145 | let select = select.into_pub_fields(); 146 | Element { 147 | id: id_gen.new_id(), 148 | name: std::borrow::Cow::Borrowed("div"), 149 | attributes: select.attrs, 150 | listeners: self.listeners, 151 | children: self 152 | .children 153 | .into_iter() 154 | .map(|child| child.into_element(id_gen)) 155 | .collect(), 156 | text: None, 157 | key: None, 158 | } 159 | } 160 | // not very useful given that Puck is entirely dependent on Javascript, but hey. 161 | BodyNode::NoScript(noscript) => { 162 | let noscript = noscript.into_pub_fields(); 163 | Element { 164 | id: id_gen.new_id(), 165 | name: std::borrow::Cow::Borrowed("div"), 166 | attributes: HashMap::new(), 167 | listeners: vec![], 168 | children: vec![], 169 | text: Some(noscript.text), 170 | key: None, 171 | } 172 | } 173 | // todo: this should be fixed 174 | BodyNode::Text(_) => panic!(""), 175 | BodyNode::Img(img) => { 176 | let img = img.into_pub_fields(); 177 | Element { 178 | id: id_gen.new_id(), 179 | name: std::borrow::Cow::Borrowed("img"), 180 | attributes: img.attrs, 181 | listeners: self.listeners, 182 | children: vec![], 183 | text: None, 184 | key: None, 185 | } 186 | } 187 | } 188 | } 189 | 190 | pub fn listener(mut self, listener: impl Into) -> WrappedBodyNode { 191 | self.listeners.push(listener.into()); 192 | self 193 | } 194 | 195 | pub fn child(mut self, child: impl Into) -> WrappedBodyNode { 196 | self.children.push(child.into()); 197 | self 198 | } 199 | 200 | pub fn children( 201 | mut self, 202 | children: impl IntoIterator, 203 | ) -> WrappedBodyNode { 204 | self.children.extend(children); 205 | self 206 | } 207 | } 208 | 209 | pub trait IntoWrappedBodyNode { 210 | fn wrap(self) -> WrappedBodyNode; 211 | } 212 | 213 | impl IntoWrappedBodyNode for T 214 | where 215 | T: Into, 216 | { 217 | fn wrap(self) -> WrappedBodyNode { 218 | WrappedBodyNode { 219 | node: self.into(), 220 | listeners: vec![], 221 | children: vec![], 222 | } 223 | } 224 | } 225 | 226 | #[cfg(test)] 227 | #[lunatic::test] 228 | fn test_html_conversion() { 229 | use malvolio::prelude::*; 230 | 231 | let tree = Div::new(); 232 | let output = tree.wrap().into_element(&mut IdGen::new()); 233 | assert_eq!(&format!("{:?}", output), include_str!("tree")); 234 | 235 | let bigger_tree = Div::new().wrap().child(H1::new("Heading 1").wrap()).child( 236 | Input::new() 237 | .attribute(Type::Submit) 238 | .wrap() 239 | .listener(ListenerRef::new("a_listener", "click")), 240 | ); 241 | let output = bigger_tree.into_element(&mut IdGen::new()); 242 | assert_eq!(&format!("{:?}", output), include_str!("bigger_tree")); 243 | 244 | let id_not_starting_from_zero = Form::new() 245 | .wrap() 246 | .child(Input::new().attribute(Type::Text).wrap()) 247 | .child(Input::new().attribute(Type::Submit).wrap()); 248 | let mut idgen = IdGen::new(); 249 | idgen.new_id(); 250 | let output = id_not_starting_from_zero.into_element(&mut idgen); 251 | assert_eq!( 252 | &format!("{:?}", output), 253 | include_str!("id_not_starting_from_zero") 254 | ); 255 | } 256 | -------------------------------------------------------------------------------- /puck_liveview/src/html/snapshots/puck_liveview__html__html_conversion.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: puck_liveview/src/html/mod.rs 3 | expression: output 4 | 5 | --- 6 | Element { 7 | id: [ 8 | 0, 9 | ], 10 | name: "div", 11 | attributes: {}, 12 | listeners: [], 13 | children: [], 14 | text: None, 15 | key: None, 16 | } 17 | -------------------------------------------------------------------------------- /puck_liveview/src/html/snapshots/puck_liveview__html__html_conversion_medium.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: puck_liveview/src/html/mod.rs 3 | expression: output 4 | 5 | --- 6 | Element { 7 | id: 0, 8 | name: "div", 9 | attributes: {}, 10 | listeners: [], 11 | children: [ 12 | Element { 13 | id: 1, 14 | name: "h1", 15 | attributes: {}, 16 | listeners: [], 17 | children: [], 18 | text: Some( 19 | "Heading 1", 20 | ), 21 | key: None, 22 | }, 23 | Element { 24 | id: 2, 25 | name: "input", 26 | attributes: { 27 | "type": "submit", 28 | }, 29 | listeners: [ 30 | ListenerRef { 31 | listener_name: "a_listener", 32 | js_event: "click", 33 | }, 34 | ], 35 | children: [], 36 | text: None, 37 | key: None, 38 | }, 39 | ], 40 | text: None, 41 | key: None, 42 | } 43 | -------------------------------------------------------------------------------- /puck_liveview/src/html/snapshots/puck_liveview__html__html_conversion_offset_starting_id.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: puck_liveview/src/html/mod.rs 3 | expression: output 4 | 5 | --- 6 | Element { 7 | id: 1, 8 | name: "form", 9 | attributes: {}, 10 | listeners: [], 11 | children: [ 12 | Element { 13 | id: 2, 14 | name: "input", 15 | attributes: { 16 | "type": "text", 17 | }, 18 | listeners: [], 19 | children: [], 20 | text: None, 21 | key: None, 22 | }, 23 | Element { 24 | id: 3, 25 | name: "input", 26 | attributes: { 27 | "type": "submit", 28 | }, 29 | listeners: [], 30 | children: [], 31 | text: None, 32 | key: None, 33 | }, 34 | ], 35 | text: None, 36 | key: None, 37 | } 38 | -------------------------------------------------------------------------------- /puck_liveview/src/html/snapshots/puck_liveview__html__html_conversion_simple.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: puck_liveview/src/html/mod.rs 3 | expression: output 4 | 5 | --- 6 | Element { 7 | id: 0, 8 | name: "div", 9 | attributes: {}, 10 | listeners: [], 11 | children: [], 12 | text: None, 13 | key: None, 14 | } 15 | -------------------------------------------------------------------------------- /puck_liveview/src/html/tree: -------------------------------------------------------------------------------- 1 | Element { id: 0, name: "div", attributes: {}, listeners: [], children: [], text: None, key: None } -------------------------------------------------------------------------------- /puck_liveview/src/init.rs: -------------------------------------------------------------------------------- 1 | use puck::{body::Body, Response}; 2 | 3 | /// Returns the index page to the client. 4 | /// 5 | /// You can mount this anywhere, but make sure that you mount an instance of `js` at 6 | /// `//js`. 7 | pub fn index() -> Response { 8 | Response::build() 9 | .header("Content-Type", "text/html") 10 | .body(Body::from_string( 11 | r#" 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | "#, 21 | )) 22 | .build() 23 | } 24 | 25 | /// Returns the JS needed for the application to the client. 26 | /// 27 | /// You need to mount this at /js 28 | pub fn js() -> Response { 29 | Response::build() 30 | .header("Content-Type", "application/javascript") 31 | .body(Body::from_string(include_str!("../client/index.js"))) 32 | .build() 33 | } 34 | -------------------------------------------------------------------------------- /puck_liveview/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate derive_builder; 3 | 4 | pub mod client; 5 | pub mod component; 6 | pub mod dom; 7 | pub mod html; 8 | pub mod init; 9 | 10 | #[cfg(test)] 11 | #[cfg(feature = "apply")] 12 | mod regressions; 13 | 14 | pub mod prelude { 15 | pub use crate::html::{IntoWrappedBodyNode, WrappedBodyNode}; 16 | pub use malvolio::prelude::*; 17 | } 18 | -------------------------------------------------------------------------------- /puck_liveview/src/regressions/mod.rs: -------------------------------------------------------------------------------- 1 | use malvolio::prelude::*; 2 | 3 | use std::collections::HashMap; 4 | 5 | use crate::dom::element::Element; 6 | use crate::dom::listener::ListenerRef; 7 | use crate::prelude::*; 8 | 9 | use crate::html::id::IdGen; 10 | 11 | #[lunatic::test] 12 | fn diffing_regression_2021_06_06() { 13 | let mut before = H1::new("") 14 | .raw_attribute("¡", "") 15 | .wrap() 16 | .into_element(&mut IdGen::new()); 17 | let expected_after = H1::new("").wrap().into_element(&mut IdGen::new()); 18 | 19 | let diff_before = before.clone(); 20 | 21 | let cs = diff_before.diff(Some(&expected_after)); 22 | 23 | cs.apply(&mut before); 24 | 25 | assert_eq!(before, expected_after); 26 | } 27 | 28 | #[lunatic::test] 29 | fn diffing_regression_2022_01_23() { 30 | let mut before = Element { 31 | id: 0, 32 | name: "div".into(), 33 | attributes: HashMap::new(), 34 | listeners: vec![], 35 | children: vec![ 36 | Element { 37 | id: 1, 38 | name: "div".into(), 39 | attributes: HashMap::new(), 40 | listeners: vec![], 41 | children: vec![Element { 42 | id: 2, 43 | name: "input".into(), 44 | attributes: { 45 | let mut res = HashMap::new(); 46 | res.insert("type".into(), "text".into()); 47 | res.insert("value".into(), "name".into()); 48 | res.insert("placeholder".into(), "username...".into()); 49 | res 50 | }, 51 | listeners: vec![ListenerRef { 52 | listener_name: "set_username".into(), 53 | js_event: "input".into(), 54 | }], 55 | children: vec![], 56 | text: None, 57 | key: None, 58 | }], 59 | text: None, 60 | key: None, 61 | }, 62 | Element { 63 | id: 3, 64 | name: "div".into(), 65 | attributes: HashMap::new(), 66 | listeners: vec![], 67 | children: vec![Element { 68 | id: 4, 69 | name: "input".into(), 70 | attributes: { 71 | let mut res = HashMap::new(); 72 | res.insert("type".into(), "submit".into()); 73 | res 74 | }, 75 | listeners: vec![ListenerRef { 76 | listener_name: "initialize_username".into(), 77 | js_event: "click".into(), 78 | }], 79 | children: vec![], 80 | text: None, 81 | key: None, 82 | }], 83 | text: None, 84 | key: None, 85 | }, 86 | ], 87 | text: None, 88 | key: None, 89 | }; 90 | 91 | let after = Element { 92 | id: 0, 93 | name: "div".into(), 94 | attributes: HashMap::new(), 95 | listeners: vec![], 96 | children: vec![Element { 97 | id: 1, 98 | name: "div".into(), 99 | attributes: HashMap::new(), 100 | listeners: vec![], 101 | children: vec![ 102 | Element { 103 | id: 2, 104 | name: "p".into(), 105 | attributes: HashMap::new(), 106 | listeners: vec![], 107 | children: vec![], 108 | text: Some("Hello teymour (user_id: 0)!".into()), 109 | key: None, 110 | }, 111 | Element { 112 | id: 3, 113 | name: "div".into(), 114 | attributes: HashMap::new(), 115 | listeners: vec![], 116 | children: vec![Element { 117 | id: 4, 118 | name: "input".into(), 119 | attributes: { 120 | let mut res = HashMap::new(); 121 | res.insert("placeholder".into(), "send a new message...".into()); 122 | res.insert("type".into(), "text".into()); 123 | res.insert("value".into(), "".into()); 124 | res 125 | }, 126 | listeners: vec![ListenerRef { 127 | listener_name: "update_message_contents".into(), 128 | js_event: "input".into(), 129 | }], 130 | children: vec![], 131 | text: None, 132 | key: None, 133 | }], 134 | text: None, 135 | key: None, 136 | }, 137 | Element { 138 | id: 5, 139 | name: "div".into(), 140 | attributes: HashMap::new(), 141 | listeners: vec![], 142 | children: vec![Element { 143 | id: 6, 144 | name: "input".into(), 145 | attributes: { 146 | let mut res = HashMap::new(); 147 | res.insert("type".into(), "submit".into()); 148 | res.insert("value".into(), "send message".into()); 149 | res 150 | }, 151 | listeners: vec![ListenerRef { 152 | listener_name: "submit_message".into(), 153 | js_event: "click".into(), 154 | }], 155 | children: vec![], 156 | text: None, 157 | key: None, 158 | }], 159 | text: None, 160 | key: None, 161 | }, 162 | ], 163 | text: None, 164 | key: None, 165 | }], 166 | text: None, 167 | key: None, 168 | }; 169 | 170 | let before2 = before.clone(); 171 | let cs = before2.diff(Some(&after)); 172 | 173 | cs.apply(&mut before); 174 | 175 | assert_eq!(before, after); 176 | } 177 | 178 | #[lunatic::test] 179 | fn simple_listener_test() { 180 | let mut before = Element { 181 | id: 0, 182 | listeners: vec![ 183 | ListenerRef::new("one", "two"), 184 | ListenerRef::new("three", "four"), 185 | ], 186 | ..Default::default() 187 | }; 188 | let after = Element { 189 | id: 0, 190 | listeners: vec![ListenerRef::new("one", "two")], 191 | ..Default::default() 192 | }; 193 | let before2 = before.clone(); 194 | let cs = before2.diff(Some(&after)); 195 | 196 | cs.apply(&mut before); 197 | assert_eq!(before, after); 198 | } 199 | -------------------------------------------------------------------------------- /puck_liveview/tests/diffing.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "_fuzz")] 2 | mod test { 3 | use fuzzcheck::fuzz_test; 4 | 5 | use puck_liveview::{ 6 | html::id::IdGen, 7 | prelude::{BodyNode, IntoWrappedBodyNode}, 8 | }; 9 | 10 | #[allow(unused)] 11 | fn test_diff((before, after): &(BodyNode, BodyNode)) -> bool { 12 | let before = before.clone(); 13 | let after = after.clone(); 14 | let mut before = before.wrap().into_element(&mut IdGen::new()); 15 | let after = after.wrap().into_element(&mut IdGen::new()); 16 | 17 | let mut before2 = before.clone(); 18 | let cs = before2.diff(Some(&after)); 19 | 20 | cs.apply(&mut before); 21 | 22 | before == after 23 | } 24 | 25 | #[test] 26 | fn fuzz_diffing() { 27 | let res = fuzz_test(test_diff).default_options().launch(); 28 | assert!(!res.found_test_failure); 29 | } 30 | 31 | fn test_regression(data: &str) { 32 | let data: (BodyNode, BodyNode) = serde_json::from_str(data).unwrap(); 33 | 34 | assert!(test_diff(&data)); 35 | } 36 | 37 | #[test] 38 | fn many_cov_hits() { 39 | test_regression(r#"[{"A":{"attrs":{},"text":""}},{"Label":{"text":"","attrs":{}}}]"#); 40 | test_regression( 41 | r#"[{"Img":{"attrs":{"9":"6","p":"","F":""}}},{"H2":{"text":"","attrs":{"p":"","C":"","9":""}}}]"#, 42 | ); 43 | } 44 | 45 | #[test] 46 | fn max_cov_hits() { 47 | test_regression( 48 | r#"[ 49 | { 50 | "H2": { 51 | "text": "", 52 | "attrs": { 53 | "73": "", 54 | "F": "k", 55 | "1CB66bp7saGlSaY1j": "p3AX", 56 | "g54Wr": "", 57 | "t": "Y", 58 | "O": "Z7", 59 | "Iaq6": "", 60 | "H": "", 61 | "46": "7", 62 | "S3": "", 63 | "d": "", 64 | "FeB": "", 65 | "e6": "1", 66 | "eo": "", 67 | "2": "", 68 | "1": "3", 69 | "11": "V", 70 | "o": "", 71 | "N": "h", 72 | "8": "A", 73 | "03": "", 74 | "i": "", 75 | "pa": "", 76 | "312RPqrkF6nFuk3vdhZ8FOL": "8FGWGKcAn6e", 77 | "de4": "q", 78 | "L": "", 79 | "K": "", 80 | "88": "", 81 | "NX2T": "", 82 | "m0": "", 83 | "m": "", 84 | "V8": "", 85 | "El": "e2", 86 | "0": "", 87 | "1N": "b", 88 | "w01": "6", 89 | "f": "", 90 | "b3": "", 91 | "9": "uni", 92 | "sf": "", 93 | "nR": "", 94 | "4h5sjgcV5juESPjm2I5VilqD": "7q35G8Y0AR4C6A", 95 | "6b8": "", 96 | "D": "", 97 | "z": "s", 98 | "kY": "", 99 | "Oc": "", 100 | "6": "", 101 | "Y": "", 102 | "aN": "", 103 | "Z": "", 104 | "r": "", 105 | "M": "", 106 | "e": "iiP", 107 | "C": "9", 108 | "B": "", 109 | "0J": "", 110 | "3orR4w7pOO35q": "", 111 | "Us": "", 112 | "5": "", 113 | "7": "", 114 | "1ry962G8klM5v41qDpfvQ093BGfRonf9Ij1p": "zp8K3Zf6JoiLg", 115 | "I": "", 116 | "l": "", 117 | "c": "", 118 | "er": "", 119 | "X3": "", 120 | "w": "J5Y", 121 | "R": "7", 122 | "U": "", 123 | "n": "", 124 | "8N": "", 125 | "Ne": "", 126 | "oK": "", 127 | "p": "wkNqQ", 128 | "u": "", 129 | "g": "", 130 | "b": "F", 131 | "4OB": "", 132 | "Q": "", 133 | "V": "", 134 | "A": "", 135 | "6VDM": "3wN1s", 136 | "h": "N", 137 | "48": "", 138 | "G": "5whqU", 139 | "N3": "", 140 | "X": "", 141 | "s": "", 142 | "68": "6", 143 | "S": "", 144 | "a": "", 145 | "Awrv1Eb128Wc": "m1v", 146 | "k": "", 147 | "y7": "", 148 | "jtq": "", 149 | "x": "", 150 | "q": "e", 151 | "ep": "", 152 | "x0MhH": "", 153 | "v": "", 154 | "4": "", 155 | "01": "4FTb1", 156 | "P": "X", 157 | "k40": "L", 158 | "E": "", 159 | "T": "", 160 | "y": "K", 161 | "jsdTvQrrmK74022": "OTDJlkZM71v4lNW8", 162 | "GUw": "OcUB9G" 163 | } 164 | } 165 | }, 166 | { 167 | "Form": { 168 | "children": [ 169 | { 170 | "A": { 171 | "attrs": {}, 172 | "text": "" 173 | } 174 | }, 175 | { 176 | "Label": { 177 | "text": "", 178 | "attrs": {} 179 | } 180 | }, 181 | { 182 | "Input": { 183 | "attrs": {} 184 | } 185 | }, 186 | { 187 | "Br": null 188 | }, 189 | { 190 | "Form": { 191 | "children": [ 192 | { 193 | "Div": { 194 | "children": [], 195 | "attrs": {} 196 | } 197 | } 198 | ], 199 | "attrs": {} 200 | } 201 | }, 202 | { 203 | "P": { 204 | "attrs": {}, 205 | "text": "", 206 | "children": [ 207 | { 208 | "Form": { 209 | "children": [], 210 | "attrs": {} 211 | } 212 | } 213 | ] 214 | } 215 | }, 216 | { 217 | "NoScript": { 218 | "text": "" 219 | } 220 | }, 221 | { 222 | "Div": { 223 | "children": [ 224 | { 225 | "Form": { 226 | "children": [ 227 | { 228 | "NoScript": { 229 | "text": "" 230 | } 231 | } 232 | ], 233 | "attrs": {} 234 | } 235 | } 236 | ], 237 | "attrs": {} 238 | } 239 | }, 240 | { 241 | "P": { 242 | "attrs": {}, 243 | "text": "", 244 | "children": [ 245 | { 246 | "P": { 247 | "attrs": {}, 248 | "text": "", 249 | "children": [ 250 | { 251 | "Div": { 252 | "children": [], 253 | "attrs": {} 254 | } 255 | } 256 | ] 257 | } 258 | } 259 | ] 260 | } 261 | }, 262 | { 263 | "P": { 264 | "attrs": {}, 265 | "text": "", 266 | "children": [] 267 | } 268 | }, 269 | { 270 | "Form": { 271 | "children": [ 272 | { 273 | "Div": { 274 | "children": [], 275 | "attrs": {} 276 | } 277 | } 278 | ], 279 | "attrs": {} 280 | } 281 | }, 282 | { 283 | "P": { 284 | "attrs": {}, 285 | "text": "", 286 | "children": [ 287 | { 288 | "Form": { 289 | "children": [], 290 | "attrs": {} 291 | } 292 | } 293 | ] 294 | } 295 | }, 296 | { 297 | "P": { 298 | "attrs": {}, 299 | "text": "", 300 | "children": [] 301 | } 302 | }, 303 | { 304 | "Div": { 305 | "children": [ 306 | { 307 | "Form": { 308 | "children": [ 309 | { 310 | "Div": { 311 | "children": [], 312 | "attrs": {} 313 | } 314 | } 315 | ], 316 | "attrs": {} 317 | } 318 | } 319 | ], 320 | "attrs": {} 321 | } 322 | }, 323 | { 324 | "Div": { 325 | "children": [ 326 | { 327 | "Div": { 328 | "children": [], 329 | "attrs": {} 330 | } 331 | } 332 | ], 333 | "attrs": {} 334 | } 335 | }, 336 | { 337 | "Div": { 338 | "children": [], 339 | "attrs": {} 340 | } 341 | }, 342 | { 343 | "Form": { 344 | "children": [], 345 | "attrs": {} 346 | } 347 | }, 348 | { 349 | "Div": { 350 | "children": [], 351 | "attrs": {} 352 | } 353 | }, 354 | { 355 | "Form": { 356 | "children": [ 357 | { 358 | "Div": { 359 | "children": [ 360 | { 361 | "P": { 362 | "attrs": {}, 363 | "text": "", 364 | "children": [] 365 | } 366 | } 367 | ], 368 | "attrs": {} 369 | } 370 | } 371 | ], 372 | "attrs": {} 373 | } 374 | }, 375 | { 376 | "Div": { 377 | "children": [ 378 | { 379 | "P": { 380 | "attrs": {}, 381 | "text": "", 382 | "children": [] 383 | } 384 | } 385 | ], 386 | "attrs": {} 387 | } 388 | }, 389 | { 390 | "A": { 391 | "attrs": {}, 392 | "text": "" 393 | } 394 | }, 395 | { 396 | "P": { 397 | "attrs": {}, 398 | "text": "", 399 | "children": [ 400 | { 401 | "Form": { 402 | "children": [], 403 | "attrs": {} 404 | } 405 | } 406 | ] 407 | } 408 | } 409 | ], 410 | "attrs": { 411 | "3": "", 412 | "9": "", 413 | "Ye": "", 414 | "G": "L", 415 | "n": "e3N", 416 | "sZ": "H", 417 | "220H": "", 418 | "P": "" 419 | } 420 | } 421 | } 422 | ]"#, 423 | ); 424 | } 425 | } 426 | -------------------------------------------------------------------------------- /scripts/server_test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Author michael 3 | set -euo pipefail 4 | set -x 5 | 6 | function cleanup() { 7 | kill -9 ${WSSERVER_PID} 8 | } 9 | trap cleanup TERM EXIT 10 | 11 | function test_diff() { 12 | if ! diff -q \ 13 | <(jq -S 'del(."Puck" | .. | .duration?)' 'autobahn/server-results.json') \ 14 | <(jq -S 'del(."Puck" | .. | .duration?)' 'autobahn/server/index.json') 15 | then 16 | echo 'Difference in results, either this is a regression or' \ 17 | 'autobahn/expected-results.json needs to be updated with the new results.' 18 | exit 64 19 | fi 20 | } 21 | 22 | cargo build --release --bin echo 23 | 24 | cargo run --release --bin echo & WSSERVER_PID=$! 25 | 26 | docker run --rm \ 27 | -v "${PWD}/autobahn:/autobahn" \ 28 | --network host \ 29 | crossbario/autobahn-testsuite \ 30 | wstest -m fuzzingclient -s './autobahn/fuzzingclient.json' 31 | 32 | test_diff 33 | --------------------------------------------------------------------------------