mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-29 13:52:46 -07:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77b34fbb7b | |||
| d3040aa63f | |||
| 672e4b69d7 | |||
| 9cd90ba62a | |||
| b6d0bb13ef | |||
| 7401a59346 | |||
| 622ae93d68 | |||
| b4207feb7e | |||
| 332fdb70ca | |||
| b9abfb64c0 | |||
| 3ca8861d85 | |||
| 0c93aab13f | |||
| 208b49cfc8 | |||
| db0a2d9d1d | |||
| c123e93433 | |||
| 35557d296d | |||
| d0d6550d7e | |||
| 38d9be2bb0 | |||
| fcb33d208c | |||
| f831ad6c7a | |||
| 3bfed5fabc | |||
| e1ce3ba2fd | |||
| a31f048ffd | |||
| 9c707466db | |||
| 4ff899729b | |||
| a8345d3509 | |||
| 1c3d6e2d78 | |||
| dde5ae7827 | |||
| aeef19cda3 | |||
| adec05c6f8 | |||
| 2c1e8f7db8 | |||
| 2c1e8f0cc2 | |||
| 5d434aeb68 | |||
| 54e7d3a397 | |||
| 7937291281 | |||
| 777f932266 | |||
| f37cb701b3 | |||
| 6498905377 | |||
| ed6c75fe37 | |||
| f759960641 | |||
| 75e8e51464 | |||
| 75ee140525 | |||
| 8d09637a7a | |||
| 62bfd35fbd | |||
| 091c94d609 | |||
| a72656cb4b | |||
| b3ef9f0ee6 | |||
| 0e4debdb8a | |||
| 476fae0e70 | |||
| 3de9d1be66 | |||
| 00916f188d | |||
| fff5f92893 | |||
| 8b796fc1f4 | |||
| 5ac679785b | |||
| 38b6cc8c65 | |||
| abb2d658c6 | |||
| 3587c085b9 | |||
| 581a7d79b3 | |||
| e21b80a5d8 | |||
| e0fc19ecaf | |||
| 0be0ce4c15 | |||
| 792358ea9c | |||
| 26c0c2488d | |||
| 70b4b9c28f | |||
| 8b4db423b4 |
@@ -0,0 +1 @@
|
|||||||
|
examples/** linguist-documentation
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: pnpm publish --no-git-checks --dry-run --filter ./packages/tuono
|
run: pnpm publish -r --no-git-checks --dry-run
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: pnpm publish --no-git-checks --filter ./packages/tuono
|
run: pnpm publish -r --no-git-checks
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Rust CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- '.github/**'
|
||||||
|
- 'crates/**'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/**'
|
||||||
|
- 'crates/**'
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint_and_fmt:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: cargo fmt --all -- --check
|
||||||
|
- run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
build_and_test:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||||
|
name: Build and test rust crates
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
toolchain:
|
||||||
|
- stable
|
||||||
|
- beta
|
||||||
|
- nightly
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
|
||||||
|
- run: cargo build --verbose
|
||||||
|
- run: cargo test --verbose
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Typescript CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- '.github/**'
|
||||||
|
- 'packages/**'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/**'
|
||||||
|
- 'packages/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||||
|
timeout-minutes: 15
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm i -g pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Test project
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
fmt-lint-and-types:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm i -g pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: pnpm format:check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Types
|
||||||
|
run: pnpm types
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
valerioageno@yahoo.it.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|

|
||||||
|

|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
# How to contribute to Tuono
|
||||||
|
|
||||||
|
## Contributions
|
||||||
|
Any feature contribution or suggestion is strongly appreciated.
|
||||||
|
Since the current project size there isn't yet a defined way to start a discussion. Consider to [open a new issue](https://github.com/Valerioageno/tuono/issues/new)
|
||||||
|
or to reach me using my email address [valerioageno@yahoo.it](mailto:valerioageno@ahoo.it). I'm also available on twitter (X) DMs @valerioageno.
|
||||||
|
|
||||||
|
## Bugs
|
||||||
|
|
||||||
|
**Did you find a bug?**
|
||||||
|
- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/Valerioageno/tuono/issues).
|
||||||
|
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/Valerioageno/tuono/issues/new). Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring.
|
||||||
|
|
||||||
|
**Did you write a patch that fixes a bug?**
|
||||||
|
- Open a new GitHub pull request with the patch.
|
||||||
|
- Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
||||||
|
- The pull requests must pass all the CI pipelines
|
||||||
@@ -5,72 +5,95 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
⚠️ This project is under heavy development. API might drastically change ⚠️
|
⚠️ This project is under heavy development. API might drastically change ⚠️
|
||||||
</p>
|
</p>
|
||||||
|
<div align="center">
|
||||||
|
<img src="https://github.com/Valerioageno/tuono/actions/workflows/rust.yml/badge.svg" />
|
||||||
|
<img src="https://github.com/Valerioageno/tuono/actions/workflows/typescript.yml/badge.svg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
|
||||||
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
|
Tuono (Italian word for "thunder", pronounced /2 Oh No/).
|
||||||
Why Tuono? Just a badass name.
|
Why Tuono? Just a badass name.
|
||||||
|
|
||||||
> If you want to see how this project actually works check the [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) page.
|
> Check out the [tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md) to get started.
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
NodeJs/Deno/Bun are the only tools that make a React app fullstack right? (no)
|
**NodeJs/Deno/Bun are the only runtimes that allow a React app to be fullstack right? (no)**
|
||||||
|
|
||||||
Tuono wants to prove that it's possible creating fully fledged react applications without the need to host them on a JS runtime server leveraging the best of the two worlds:
|
Tuono is a fullstack React framework with the server side written in Rust.
|
||||||
super powered server and amazing development experience.
|
Because of this Tuono is extremely fast and the requests are handled by multithreaded Rust server.
|
||||||
|
React is still React - it is just superpowered.
|
||||||
|
|
||||||
|
**Rust is an hard language then writing server side code is hard as well right? (no again)**
|
||||||
|
|
||||||
|
Tuono provides a collection of utilities to handle the server side code seamlessly with the React code.
|
||||||
|
Each server side route is managed with a separate file alongside the React route. The routing is handled
|
||||||
|
by Tuono based on the files defined within the `./src/routes` directory.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🟦 Typescript
|
||||||
|
- 🌐 Routing
|
||||||
|
- 🔥 Hot Module Reload
|
||||||
|
- 🍭 CSS modules
|
||||||
|
- 🧬 Server Side Rendering
|
||||||
|
- 🧵 Multi thread backend
|
||||||
|
- ⚙️ Build optimizations
|
||||||
|
- Custom APIs*
|
||||||
|
- Image optimization*
|
||||||
|
- Server streamed content*
|
||||||
|
|
||||||
|
> *Development in progress
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
As already mentioned above I strongly suggest you to take a look at the
|
||||||
|
[tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md).
|
||||||
|
|
||||||
|
Tuono is basically a CLI that provides all the commands to handle the fullstack project.
|
||||||
|
To download it you need [cargo](https://doc.rust-lang.org/cargo/) which is the [rust](https://www.rust-lang.org/)
|
||||||
|
package manager.
|
||||||
|
|
||||||
|
To download and install it you just need to run `cargo install tuono`.
|
||||||
|
|
||||||
|
To create a new project run `tuono new [NAME]` (optionally you can pass the `--template` flag - check the
|
||||||
|
[examples](https://github.com/Valerioageno/tuono/tree/main/examples) folder).
|
||||||
|
|
||||||
|
Then to run the local development environment run inside the project folder `tuono dev`
|
||||||
|
|
||||||
|
Finally when the project will be ready to be deployed just run `tuono build` to create the final React assets
|
||||||
|
and to set the server project to the `production` mode.
|
||||||
|
|
||||||
|
Now to execute it just run `cargo run --release`.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- rust
|
- rust
|
||||||
- cargo
|
- cargo
|
||||||
- node
|
- node
|
||||||
- pnpm (other package managers support will be added soon)
|
- npm/pnpm/yarn*
|
||||||
|
|
||||||
## Installation
|
> yarn [pnp](https://yarnpkg.com/features/pnp) is not supported yet
|
||||||
|
|
||||||
```
|
|
||||||
cargo install tuono
|
|
||||||
```
|
|
||||||
|
|
||||||
## Create a new project
|
|
||||||
|
|
||||||
```
|
|
||||||
tuono new [NAME]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```
|
|
||||||
tuono dev
|
|
||||||
```
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- [x] FS routing
|
|
||||||
- [x] Hot Module Reload
|
|
||||||
- [x] CSS modules
|
|
||||||
- [x] Rust based server side rendering
|
|
||||||
- [x] Multi thread backend
|
|
||||||
- [x] Development environment
|
|
||||||
- [ ] Create custom APIs
|
|
||||||
- [ ] Image optimization
|
|
||||||
- [ ] Build optimization
|
|
||||||
- [ ] Server streamed content
|
|
||||||
|
|
||||||
> 💡 Any suggestion or improvement is strongly appreciated
|
|
||||||
|
|
||||||
## Folder structure
|
## Folder structure
|
||||||
|
|
||||||
```
|
```
|
||||||
| public/
|
├── package.json
|
||||||
- src/
|
├── public
|
||||||
| routes/
|
├── src
|
||||||
| styles/
|
│ ├── routes
|
||||||
| package.json
|
│ └── styles
|
||||||
| Cargo.toml
|
├── Cargo.toml
|
||||||
| .gitignore
|
├── README.md
|
||||||
| tsconfig.json
|
└── tsconfig.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
Any help or suggestion will be appreciated and encouraged.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
This project is licensed under the MIT License.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono"
|
name = "tuono"
|
||||||
version = "0.1.2"
|
version = "0.4.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||||
description = "The react/rust fullstack framework"
|
description = "The react/rust fullstack framework"
|
||||||
homepage = "https://github.com/Valerioageno/tuono"
|
repository = "https://github.com/Valerioageno/tuono"
|
||||||
readme = "../../README.md"
|
readme = "../../README.md"
|
||||||
license-file = "../../LICENSE.md"
|
license-file = "../../LICENSE.md"
|
||||||
categories = ["web-programming"]
|
categories = ["web-programming"]
|
||||||
|
|||||||
+19
-8
@@ -4,7 +4,8 @@ use std::process::Command;
|
|||||||
mod source_builder;
|
mod source_builder;
|
||||||
use source_builder::{bundle_axum_source, create_client_entry_files};
|
use source_builder::{bundle_axum_source, create_client_entry_files};
|
||||||
|
|
||||||
use crate::source_builder::check_tuono_folder;
|
use crate::source_builder::{check_tuono_folder, Mode};
|
||||||
|
|
||||||
mod scaffold_project;
|
mod scaffold_project;
|
||||||
mod watch;
|
mod watch;
|
||||||
|
|
||||||
@@ -15,7 +16,14 @@ enum Actions {
|
|||||||
/// Build the production assets
|
/// Build the production assets
|
||||||
Build,
|
Build,
|
||||||
/// Scaffold a new project
|
/// Scaffold a new project
|
||||||
New { folder_name: Option<String> },
|
New {
|
||||||
|
/// The folder in which load the project. Default is the current directory.
|
||||||
|
folder_name: Option<String>,
|
||||||
|
/// The template to use to scaffold the project. The template should match one of the tuono
|
||||||
|
/// examples
|
||||||
|
#[arg(short, long)]
|
||||||
|
template: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -25,9 +33,9 @@ struct Args {
|
|||||||
action: Actions,
|
action: Actions,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_tuono_folder() -> std::io::Result<()> {
|
fn init_tuono_folder(mode: Mode) -> std::io::Result<()> {
|
||||||
check_tuono_folder()?;
|
check_tuono_folder()?;
|
||||||
bundle_axum_source()?;
|
bundle_axum_source(mode)?;
|
||||||
create_client_entry_files()?;
|
create_client_entry_files()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -38,16 +46,19 @@ pub fn cli() -> std::io::Result<()> {
|
|||||||
|
|
||||||
match args.action {
|
match args.action {
|
||||||
Actions::Dev => {
|
Actions::Dev => {
|
||||||
init_tuono_folder()?;
|
init_tuono_folder(Mode::Dev)?;
|
||||||
watch::watch().unwrap();
|
watch::watch().unwrap();
|
||||||
}
|
}
|
||||||
Actions::Build => {
|
Actions::Build => {
|
||||||
init_tuono_folder()?;
|
init_tuono_folder(Mode::Prod)?;
|
||||||
let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod");
|
let mut vite_build = Command::new("./node_modules/.bin/tuono-build-prod");
|
||||||
let _ = &vite_build.output()?;
|
let _ = &vite_build.output()?;
|
||||||
}
|
}
|
||||||
Actions::New { folder_name } => {
|
Actions::New {
|
||||||
scaffold_project::create_new_project(folder_name);
|
folder_name,
|
||||||
|
template,
|
||||||
|
} => {
|
||||||
|
scaffold_project::create_new_project(folder_name, template);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,11 @@ fn create_file(path: PathBuf, content: String) -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_new_project(folder_name: Option<String>) {
|
pub fn create_new_project(folder_name: Option<String>, template: Option<String>) {
|
||||||
let folder = folder_name.unwrap_or(".".to_string());
|
let folder = folder_name.unwrap_or(".".to_string());
|
||||||
|
|
||||||
if folder != "." {
|
// In case of missing select the tuono example
|
||||||
create_dir(&folder).unwrap();
|
let template = template.unwrap_or("tuono".to_string());
|
||||||
}
|
|
||||||
|
|
||||||
let client = blocking::Client::builder()
|
let client = blocking::Client::builder()
|
||||||
.user_agent("")
|
.user_agent("")
|
||||||
@@ -60,21 +59,25 @@ pub fn create_new_project(folder_name: Option<String>) {
|
|||||||
let new_project_files = res
|
let new_project_files = res
|
||||||
.tree
|
.tree
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|GithubFile { path, .. }| {
|
.filter(|GithubFile { path, .. }| path.starts_with(&format!("examples/{template}/")))
|
||||||
// TODO: Handle custom example download by CLI argument --template
|
|
||||||
if path.starts_with("examples/tuono/") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
})
|
|
||||||
.collect::<Vec<&GithubFile>>();
|
.collect::<Vec<&GithubFile>>();
|
||||||
|
|
||||||
|
if new_project_files.is_empty() {
|
||||||
|
println!("Template not found: {template}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if folder != "." {
|
||||||
|
create_dir(&folder).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
let folder_name = PathBuf::from(&folder);
|
let folder_name = PathBuf::from(&folder);
|
||||||
let current_dir = env::current_dir().expect("Failed to get current working directory");
|
let current_dir = env::current_dir().expect("Failed to get current working directory");
|
||||||
|
|
||||||
let folder_path = current_dir.join(folder_name);
|
let folder_path = current_dir.join(folder_name);
|
||||||
|
|
||||||
create_directories(&new_project_files, &folder_path).expect("Failed to create directories");
|
create_directories(&new_project_files, &folder_path, &template)
|
||||||
|
.expect("Failed to create directories");
|
||||||
|
|
||||||
for GithubFile {
|
for GithubFile {
|
||||||
element_type, path, ..
|
element_type, path, ..
|
||||||
@@ -88,7 +91,7 @@ pub fn create_new_project(folder_name: Option<String>) {
|
|||||||
.text()
|
.text()
|
||||||
.expect("Failed to parse the repo structure");
|
.expect("Failed to parse the repo structure");
|
||||||
|
|
||||||
let path = PathBuf::from(&path.replace("examples/tuono/", ""));
|
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||||
|
|
||||||
let file_path = folder_path.join(&path);
|
let file_path = folder_path.join(&path);
|
||||||
|
|
||||||
@@ -101,13 +104,17 @@ pub fn create_new_project(folder_name: Option<String>) {
|
|||||||
outro(folder);
|
outro(folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_directories(new_project_files: &Vec<&GithubFile>, folder_path: &Path) -> io::Result<()> {
|
fn create_directories(
|
||||||
|
new_project_files: &[&GithubFile],
|
||||||
|
folder_path: &Path,
|
||||||
|
template: &String,
|
||||||
|
) -> io::Result<()> {
|
||||||
for GithubFile {
|
for GithubFile {
|
||||||
element_type, path, ..
|
element_type, path, ..
|
||||||
} in new_project_files.iter()
|
} in new_project_files.iter()
|
||||||
{
|
{
|
||||||
if let GithubFileType::Tree = element_type {
|
if let GithubFileType::Tree = element_type {
|
||||||
let path = PathBuf::from(&path.replace("examples/tuono/", ""));
|
let path = PathBuf::from(&path.replace(&format!("examples/{template}/"), ""));
|
||||||
|
|
||||||
let dir_path = folder_path.join(&path);
|
let dir_path = folder_path.join(&path);
|
||||||
create_dir(&dir_path).unwrap();
|
create_dir(&dir_path).unwrap();
|
||||||
@@ -158,7 +165,7 @@ fn outro(folder_name: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("\nInstall the dependencies:");
|
println!("\nInstall the dependencies:");
|
||||||
println!("pnpm install");
|
println!("npm install");
|
||||||
|
|
||||||
println!("\nRun the local environment:");
|
println!("\nRun the local environment:");
|
||||||
println!("tuono dev");
|
println!("tuono dev");
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ use std::io::prelude::*;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
Prod,
|
||||||
|
Dev,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
pub fn as_str<'a>(&self) -> &'a str {
|
||||||
|
if *self == Mode::Dev {
|
||||||
|
return "Mode::Dev";
|
||||||
|
}
|
||||||
|
"Mode::Prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono
|
pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono
|
||||||
// Do not manually update this file
|
// Do not manually update this file
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
@@ -32,60 +47,25 @@ pub const AXUM_ENTRY_POINT: &str = r##"
|
|||||||
// File automatically generated
|
// File automatically generated
|
||||||
// Do not manually change it
|
// Do not manually change it
|
||||||
|
|
||||||
use axum::extract::{Path, Request};
|
use tuono_lib::{tokio, Mode, Server, axum::Router, axum::routing::get};
|
||||||
use axum::response::Html;
|
|
||||||
use axum::{routing::get, Router};
|
const MODE: Mode = /*MODE*/;
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use tuono_lib::{ssr, Ssr};
|
|
||||||
use reqwest::Client;
|
|
||||||
|
|
||||||
// MODULE_IMPORTS
|
// MODULE_IMPORTS
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
Ssr::create_platform();
|
let router = Router::new()
|
||||||
|
|
||||||
let fetch = Client::new();
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
// ROUTE_BUILDER
|
// ROUTE_BUILDER
|
||||||
.fallback_service(ServeDir::new("public").fallback(get(catch_all)))
|
;
|
||||||
.with_state(fetch);
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
Server::init(router, MODE).start().await
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn catch_all(Path(params): Path<HashMap<String, String>>, request: Request) -> Html<String> {
|
|
||||||
let pathname = &request.uri();
|
|
||||||
let headers = &request.headers();
|
|
||||||
|
|
||||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
|
||||||
|
|
||||||
|
|
||||||
// TODO: remove unwrap
|
|
||||||
let payload = tuono_lib::Payload::new(&req, Box::new(""))
|
|
||||||
.client_payload()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let result = ssr::Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload)));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(html) => Html(html),
|
|
||||||
_ => Html("500 internal server error".to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"##;
|
"##;
|
||||||
|
|
||||||
const ROOT_FOLDER: &str = "src/routes";
|
const ROOT_FOLDER: &str = "src/routes";
|
||||||
const DEV_FOLDER: &str = ".tuono";
|
const DEV_FOLDER: &str = ".tuono";
|
||||||
|
|
||||||
pub enum Mode {
|
|
||||||
Prod,
|
|
||||||
Dev,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
struct Route {
|
struct Route {
|
||||||
/// Every module import is the path with a _ instead of /
|
/// Every module import is the path with a _ instead of /
|
||||||
@@ -127,7 +107,7 @@ impl Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Route {
|
Route {
|
||||||
module_import: module.as_str().to_string().replace('/', "_"),
|
module_import: module.as_str().to_string().replace('/', "_").to_lowercase(),
|
||||||
axum_route,
|
axum_route,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,17 +115,15 @@ impl Route {
|
|||||||
|
|
||||||
struct SourceBuilder {
|
struct SourceBuilder {
|
||||||
route_map: HashMap<PathBuf, Route>,
|
route_map: HashMap<PathBuf, Route>,
|
||||||
mode: Mode,
|
|
||||||
base_path: PathBuf,
|
base_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SourceBuilder {
|
impl SourceBuilder {
|
||||||
pub fn new(mode: Mode) -> Self {
|
pub fn new() -> Self {
|
||||||
let base_path = std::env::current_dir().unwrap();
|
let base_path = std::env::current_dir().unwrap();
|
||||||
|
|
||||||
SourceBuilder {
|
SourceBuilder {
|
||||||
route_map: HashMap::new(),
|
route_map: HashMap::new(),
|
||||||
mode,
|
|
||||||
base_path,
|
base_path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,14 +193,22 @@ mod {module_name};
|
|||||||
route_declarations
|
route_declarations
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bundle_axum_source() -> io::Result<()> {
|
pub fn bundle_axum_source(mode: Mode) -> io::Result<()> {
|
||||||
let base_path = std::env::current_dir().unwrap();
|
let base_path = std::env::current_dir().unwrap();
|
||||||
|
|
||||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
let mut source_builder = SourceBuilder::new();
|
||||||
|
|
||||||
source_builder.collect_routes();
|
source_builder.collect_routes();
|
||||||
|
|
||||||
let bundled_file = AXUM_ENTRY_POINT
|
let bundled_file = generate_axum_source(&source_builder, mode);
|
||||||
|
|
||||||
|
create_main_file(&base_path, &bundled_file);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_axum_source(source_builder: &SourceBuilder, mode: Mode) -> String {
|
||||||
|
AXUM_ENTRY_POINT
|
||||||
.replace(
|
.replace(
|
||||||
"// ROUTE_BUILDER\n",
|
"// ROUTE_BUILDER\n",
|
||||||
&create_routes_declaration(&source_builder.route_map),
|
&create_routes_declaration(&source_builder.route_map),
|
||||||
@@ -230,17 +216,13 @@ pub fn bundle_axum_source() -> io::Result<()> {
|
|||||||
.replace(
|
.replace(
|
||||||
"// MODULE_IMPORTS\n",
|
"// MODULE_IMPORTS\n",
|
||||||
&create_modules_declaration(&source_builder.route_map),
|
&create_modules_declaration(&source_builder.route_map),
|
||||||
);
|
)
|
||||||
|
.replace("/*MODE*/", mode.as_str())
|
||||||
create_main_file(&base_path, &bundled_file);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_tuono_folder() -> io::Result<()> {
|
pub fn check_tuono_folder() -> io::Result<()> {
|
||||||
let dev_folder = Path::new(DEV_FOLDER);
|
let dev_folder = Path::new(DEV_FOLDER);
|
||||||
if !&dev_folder.is_dir() {
|
if !&dev_folder.is_dir() {
|
||||||
println!("exists");
|
|
||||||
fs::create_dir(dev_folder)?;
|
fs::create_dir(dev_folder)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +247,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_dynamic_paths() {
|
fn should_find_dynamic_paths() {
|
||||||
let routes = [
|
let routes = [
|
||||||
("/home/user/Documents/tuono/src/routes/about.rs", false),
|
("/home/user/Documents/tuono/src/routes/about.rs", false),
|
||||||
("/home/user/Documents/tuono/src/routes/index.rs", false),
|
("/home/user/Documents/tuono/src/routes/index.rs", false),
|
||||||
@@ -285,8 +267,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn collect_routes() {
|
fn should_collect_routes() {
|
||||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
let mut source_builder = SourceBuilder::new();
|
||||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||||
|
|
||||||
let routes = [
|
let routes = [
|
||||||
@@ -294,6 +276,7 @@ mod tests {
|
|||||||
"/home/user/Documents/tuono/src/routes/index.rs",
|
"/home/user/Documents/tuono/src/routes/index.rs",
|
||||||
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
"/home/user/Documents/tuono/src/routes/posts/index.rs",
|
||||||
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
"/home/user/Documents/tuono/src/routes/posts/[post].rs",
|
||||||
|
"/home/user/Documents/tuono/src/routes/posts/UPPERCASE.rs",
|
||||||
];
|
];
|
||||||
|
|
||||||
routes
|
routes
|
||||||
@@ -305,6 +288,7 @@ mod tests {
|
|||||||
("/about.rs", "about"),
|
("/about.rs", "about"),
|
||||||
("/posts/index.rs", "posts_index"),
|
("/posts/index.rs", "posts_index"),
|
||||||
("/posts/[post].rs", "posts_dyn_post"),
|
("/posts/[post].rs", "posts_dyn_post"),
|
||||||
|
("/posts/UPPERCASE.rs", "posts_uppercase"),
|
||||||
];
|
];
|
||||||
|
|
||||||
results.into_iter().for_each(|(path, module_import)| {
|
results.into_iter().for_each(|(path, module_import)| {
|
||||||
@@ -320,8 +304,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_multi_level_axum_paths() {
|
fn should_create_multi_level_axum_paths() {
|
||||||
let mut source_builder = SourceBuilder::new(Mode::Dev);
|
let mut source_builder = SourceBuilder::new();
|
||||||
source_builder.base_path = "/home/user/Documents/tuono".into();
|
source_builder.base_path = "/home/user/Documents/tuono".into();
|
||||||
|
|
||||||
let routes = [
|
let routes = [
|
||||||
@@ -355,4 +339,24 @@ mod tests {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_set_the_correct_mode() {
|
||||||
|
let source_builder = SourceBuilder::new();
|
||||||
|
|
||||||
|
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
|
||||||
|
assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;"));
|
||||||
|
|
||||||
|
let prod_bundle = generate_axum_source(&source_builder, Mode::Prod);
|
||||||
|
|
||||||
|
assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_correctly_print_the_mode_as_str() {
|
||||||
|
let dev = Mode::Dev.as_str();
|
||||||
|
let prod = Mode::Prod.as_str();
|
||||||
|
assert_eq!(dev, "Mode::Dev");
|
||||||
|
assert_eq!(prod, "Mode::Prod");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -6,7 +6,7 @@ use watchexec::Watchexec;
|
|||||||
use watchexec_signals::Signal;
|
use watchexec_signals::Signal;
|
||||||
use watchexec_supervisor::job::{start_job, Job};
|
use watchexec_supervisor::job::{start_job, Job};
|
||||||
|
|
||||||
use crate::source_builder::bundle_axum_source;
|
use crate::source_builder::{bundle_axum_source, Mode};
|
||||||
|
|
||||||
fn watch_react_src() -> Job {
|
fn watch_react_src() -> Job {
|
||||||
start_job(Arc::new(Command {
|
start_job(Arc::new(Command {
|
||||||
@@ -54,31 +54,35 @@ pub async fn watch() -> Result<()> {
|
|||||||
|
|
||||||
run_server.start().await;
|
run_server.start().await;
|
||||||
|
|
||||||
println!("\nDevelopment app ready at http://localhost:3000/");
|
build_ssr_bundle.to_wait().await;
|
||||||
|
|
||||||
let wx = Watchexec::new(move |mut action| {
|
let wx = Watchexec::new(move |mut action| {
|
||||||
let mut should_reload = false;
|
let mut should_reload_ssr_bundle = false;
|
||||||
|
let mut should_reload_rust_server = false;
|
||||||
|
|
||||||
for event in action.events.iter() {
|
for event in action.events.iter() {
|
||||||
for path in event.paths() {
|
for path in event.paths() {
|
||||||
if path.0.to_string_lossy().ends_with(".rs")
|
if path.0.to_string_lossy().ends_with(".rs") {
|
||||||
|
should_reload_rust_server = true
|
||||||
|
}
|
||||||
|
|
||||||
// Either tsx and jsx
|
// Either tsx and jsx
|
||||||
|| path.0.to_string_lossy().ends_with("sx")
|
if path.0.to_string_lossy().ends_with("sx") {
|
||||||
{
|
should_reload_ssr_bundle = true
|
||||||
should_reload = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if should_reload {
|
if should_reload_rust_server {
|
||||||
println!("Reloading...");
|
println!("Reloading...");
|
||||||
run_server.stop();
|
run_server.stop();
|
||||||
|
bundle_axum_source(Mode::Dev).expect("Failed to bunlde rust source");
|
||||||
|
run_server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_reload_ssr_bundle {
|
||||||
build_ssr_bundle.stop();
|
build_ssr_bundle.stop();
|
||||||
build_ssr_bundle.start();
|
build_ssr_bundle.start();
|
||||||
bundle_axum_source().expect("Failed to bunlde rust source");
|
|
||||||
run_server.start();
|
|
||||||
println!("Ready!");
|
|
||||||
should_reload = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if Ctrl-C is received, quit
|
// if Ctrl-C is received, quit
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono_lib"
|
name = "tuono_lib"
|
||||||
version = "0.1.2"
|
version = "0.4.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
authors = ["V. Ageno <valerioageno@yahoo.it>"]
|
||||||
description = "The react/rust fullstack framework"
|
description = "The react/rust fullstack framework"
|
||||||
homepage = "https://github.com/Valerioageno/tuono"
|
repository = "https://github.com/Valerioageno/tuono"
|
||||||
readme = "../../README.md"
|
readme = "../../README.md"
|
||||||
license-file = "../../LICENSE.md"
|
license-file = "../../LICENSE.md"
|
||||||
categories = ["web-programming"]
|
categories = ["web-programming"]
|
||||||
@@ -18,10 +18,21 @@ name = "tuono_lib"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ssr_rs = "0.5.2"
|
ssr_rs = "0.5.5"
|
||||||
axum = "0.7.5"
|
axum = {version = "0.7.5", features = ["json", "ws"]}
|
||||||
|
tokio = { version = "1.37.0", features = ["full"] }
|
||||||
serde = { version = "1.0.202", features = ["derive"] }
|
serde = { version = "1.0.202", features = ["derive"] }
|
||||||
erased-serde = "0.4.5"
|
erased-serde = "0.4.5"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
reqwest = {version = "0.12.4", features = ["json", "stream"]}
|
||||||
|
once_cell = "1.19.0"
|
||||||
|
regex = "1.10.5"
|
||||||
|
either = "1.13.0"
|
||||||
|
tower-http = {version = "0.5.2", features = ["fs"]}
|
||||||
|
|
||||||
|
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.4.1"}
|
||||||
|
# Match the same version used by axum
|
||||||
|
tokio-tungstenite = "0.21.0"
|
||||||
|
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||||
|
tungstenite = "0.23.0"
|
||||||
|
|
||||||
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.1.2"}
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::{ssr::Js, Payload};
|
||||||
|
use axum::extract::{Path, Request};
|
||||||
|
use axum::response::Html;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub async fn catch_all(
|
||||||
|
Path(params): Path<HashMap<String, String>>,
|
||||||
|
request: Request,
|
||||||
|
) -> Html<String> {
|
||||||
|
let pathname = &request.uri();
|
||||||
|
let headers = &request.headers();
|
||||||
|
|
||||||
|
let req = crate::Request::new(pathname, headers, params);
|
||||||
|
|
||||||
|
// TODO: remove unwrap
|
||||||
|
let payload = Payload::new(&req, &"").client_payload().unwrap();
|
||||||
|
|
||||||
|
let result = Js::render_to_string(Some(&payload));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(html) => Html(html),
|
||||||
|
_ => Html("500 internal server error".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
|
mod catch_all;
|
||||||
|
mod manifest;
|
||||||
|
mod mode;
|
||||||
mod payload;
|
mod payload;
|
||||||
mod request;
|
mod request;
|
||||||
mod response;
|
mod response;
|
||||||
|
mod server;
|
||||||
|
mod ssr;
|
||||||
|
mod vite_reverse_proxy;
|
||||||
|
mod vite_websocket_proxy;
|
||||||
|
|
||||||
pub mod ssr;
|
pub use mode::Mode;
|
||||||
|
|
||||||
pub use payload::Payload;
|
pub use payload::Payload;
|
||||||
pub use request::Request;
|
pub use request::Request;
|
||||||
pub use response::Response;
|
pub use response::{Props, Response};
|
||||||
pub use ssr_rs::Ssr;
|
pub use server::Server;
|
||||||
pub use tuono_lib_macros::handler;
|
pub use tuono_lib_macros::handler;
|
||||||
|
|
||||||
|
// Re-exports
|
||||||
|
pub use axum;
|
||||||
|
pub use reqwest;
|
||||||
|
pub use tokio;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const VITE_MANIFEST_PATH: &str = "./out/client/.vite/manifest.json";
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct BundleInfo {
|
||||||
|
pub file: String,
|
||||||
|
pub css: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest is the mapping between the vite output bundled files
|
||||||
|
/// and the originals.
|
||||||
|
/// Vite doc: https://vitejs.dev/config/build-options.html#build-manifest
|
||||||
|
pub static MANIFEST: OnceCell<HashMap<String, BundleInfo>> = OnceCell::new();
|
||||||
|
|
||||||
|
pub fn load_manifest() {
|
||||||
|
let file = File::open(PathBuf::from(VITE_MANIFEST_PATH)).unwrap();
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let manifest = serde_json::from_reader(reader).unwrap();
|
||||||
|
MANIFEST.set(remap_manifest_keys(manifest)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remap_manifest_keys(manifest: HashMap<String, BundleInfo>) -> HashMap<String, BundleInfo> {
|
||||||
|
let mut new_hashmap = HashMap::new();
|
||||||
|
|
||||||
|
manifest.keys().for_each(|key| {
|
||||||
|
let new_key = key
|
||||||
|
.replace("../src/routes", "")
|
||||||
|
.replace(".tsx", "")
|
||||||
|
.replace(".jsx", "")
|
||||||
|
.replace("index", "");
|
||||||
|
|
||||||
|
new_hashmap.insert(new_key, manifest.get(key).unwrap().clone());
|
||||||
|
});
|
||||||
|
|
||||||
|
new_hashmap
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_correctly_remap_the_manifest() {
|
||||||
|
let mut parsed_manifest: HashMap<String, BundleInfo> = HashMap::new();
|
||||||
|
parsed_manifest.insert(
|
||||||
|
"../src/routes/index.tsx".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/index.js".to_string(),
|
||||||
|
css: vec!["assets/index.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
parsed_manifest.insert(
|
||||||
|
"../src/routes/about.jsx".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/about.js".to_string(),
|
||||||
|
css: vec!["assets/about.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
parsed_manifest.insert(
|
||||||
|
"../src/routes/posts/[post].tsx".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/posts/[post].js".to_string(),
|
||||||
|
css: vec!["assets/posts/[post].css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
parsed_manifest.insert(
|
||||||
|
"client-main.tsx".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/main.js".to_string(),
|
||||||
|
css: vec!["assets/main.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let remapped = remap_manifest_keys(parsed_manifest);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
remapped.get("/").unwrap().file,
|
||||||
|
"assets/index.js".to_string()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
remapped.get("/about").unwrap().file,
|
||||||
|
"assets/about.js".to_string()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
remapped.get("/posts/[post]").unwrap().file,
|
||||||
|
"assets/posts/[post].js".to_string()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
remapped.get("client-main").unwrap().file,
|
||||||
|
"assets/main.js".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, Serialize, Clone, Copy)]
|
||||||
|
pub enum Mode {
|
||||||
|
Dev,
|
||||||
|
Prod,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static GLOBAL_MODE: OnceCell<Mode> = OnceCell::new();
|
||||||
@@ -1,25 +1,294 @@
|
|||||||
|
use crate::manifest::MANIFEST;
|
||||||
|
use crate::mode::{Mode, GLOBAL_MODE};
|
||||||
use erased_serde::Serialize;
|
use erased_serde::Serialize;
|
||||||
|
use regex::Regex;
|
||||||
use serde::Serialize as SerdeSerialize;
|
use serde::Serialize as SerdeSerialize;
|
||||||
|
|
||||||
use crate::request::Location;
|
use crate::request::{Location, Request};
|
||||||
use crate::Request;
|
|
||||||
|
fn has_dynamic_path(route: &str) -> bool {
|
||||||
|
let regex = Regex::new(r"\[(.*?)\]").expect("Failed to create the regex");
|
||||||
|
regex.is_match(route)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(SerdeSerialize)]
|
#[derive(SerdeSerialize)]
|
||||||
/// This is the data shared to the client
|
/// This is the payload sent to the client for hydration
|
||||||
pub struct Payload<'a> {
|
pub struct Payload<'a> {
|
||||||
router: Location<'a>,
|
router: Location,
|
||||||
props: Box<dyn Serialize>,
|
props: &'a dyn Serialize,
|
||||||
|
mode: Mode,
|
||||||
|
#[serde(rename(serialize = "jsBundles"))]
|
||||||
|
js_bundles: Option<Vec<&'a String>>,
|
||||||
|
#[serde(rename(serialize = "cssBundles"))]
|
||||||
|
css_bundles: Option<Vec<&'a String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Payload<'a> {
|
impl<'a> Payload<'a> {
|
||||||
pub fn new(req: &Request<'a>, props: Box<dyn Serialize>) -> Payload<'a> {
|
pub fn new(req: &Request<'a>, props: &'a dyn Serialize) -> Payload<'a> {
|
||||||
Payload {
|
Payload {
|
||||||
router: req.location(),
|
router: req.location(),
|
||||||
props,
|
props,
|
||||||
|
mode: *GLOBAL_MODE.get().expect("Failed to load the current mode"),
|
||||||
|
js_bundles: None,
|
||||||
|
css_bundles: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn client_payload(&self) -> Result<String, serde_json::Error> {
|
pub fn client_payload(&mut self) -> Result<String, serde_json::Error> {
|
||||||
|
if self.mode == Mode::Prod {
|
||||||
|
self.add_bundle_sources();
|
||||||
|
}
|
||||||
serde_json::to_string(&self)
|
serde_json::to_string(&self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This method adds the route specific bundles to the server
|
||||||
|
/// side rendered HTML.
|
||||||
|
///
|
||||||
|
/// The same matching algorithm is implemented on the client side in
|
||||||
|
/// this file (packages/tuono/src/router/components/Matches.ts).
|
||||||
|
///
|
||||||
|
/// Optimizations should occour on both.
|
||||||
|
fn add_bundle_sources(&mut self) {
|
||||||
|
// Manifest should always be loaded. The load happen before starting
|
||||||
|
// the server.
|
||||||
|
let manifest = MANIFEST.get().expect("Failed to load manifest");
|
||||||
|
|
||||||
|
// The main bundle should always exist.
|
||||||
|
// The extension should be tsx even with JS only projects.
|
||||||
|
let main_bundle = manifest
|
||||||
|
.get("client-main")
|
||||||
|
.expect("Failed to get client-main bundle");
|
||||||
|
|
||||||
|
let mut js_bundles_sources = vec![&main_bundle.file];
|
||||||
|
let mut css_bundles_sources = main_bundle.css.iter().collect::<Vec<&String>>();
|
||||||
|
|
||||||
|
let pathname = &self.router.pathname();
|
||||||
|
|
||||||
|
let bundle_data = manifest.get(*pathname);
|
||||||
|
|
||||||
|
if let Some(data) = bundle_data {
|
||||||
|
js_bundles_sources.push(&data.file);
|
||||||
|
|
||||||
|
data.css
|
||||||
|
.iter()
|
||||||
|
.for_each(|source| css_bundles_sources.push(source))
|
||||||
|
} else {
|
||||||
|
let dynamic_routes = manifest
|
||||||
|
.keys()
|
||||||
|
.filter(|path| has_dynamic_path(path))
|
||||||
|
.collect::<Vec<&String>>();
|
||||||
|
|
||||||
|
if !dynamic_routes.is_empty() {
|
||||||
|
let path_segments = pathname
|
||||||
|
.split('/')
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
.collect::<Vec<&str>>();
|
||||||
|
|
||||||
|
for dyn_route in dynamic_routes.iter() {
|
||||||
|
let dyn_route_segments = dyn_route
|
||||||
|
.split('/')
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
.collect::<Vec<&str>>();
|
||||||
|
|
||||||
|
let mut route_segments_collector: Vec<&str> = vec![];
|
||||||
|
|
||||||
|
for i in 0..dyn_route_segments.len() {
|
||||||
|
if path_segments.len() == i {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if dyn_route_segments[i] == path_segments[i]
|
||||||
|
|| has_dynamic_path(dyn_route_segments[i])
|
||||||
|
{
|
||||||
|
route_segments_collector.push(dyn_route_segments[i])
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if route_segments_collector.len() == path_segments.len() {
|
||||||
|
let manifest_key = route_segments_collector.join("/");
|
||||||
|
|
||||||
|
let route_data = manifest.get(&format!("/{manifest_key}"));
|
||||||
|
if let Some(data) = route_data {
|
||||||
|
js_bundles_sources.push(&data.file);
|
||||||
|
data.css
|
||||||
|
.iter()
|
||||||
|
.for_each(|source| css_bundles_sources.push(source))
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.js_bundles = Some(js_bundles_sources);
|
||||||
|
self.css_bundles = Some(css_bundles_sources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use axum::http::Uri;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::manifest::BundleInfo;
|
||||||
|
|
||||||
|
fn prepare_payload(uri: Option<&str>, mode: Mode) -> Payload<'static> {
|
||||||
|
let mut manifest_mock = HashMap::new();
|
||||||
|
manifest_mock.insert(
|
||||||
|
"client-main".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/bundled-file.js".to_string(),
|
||||||
|
css: vec!["assets/bundled-file.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/index.js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/index.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/posts/[post]".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/posts/[post].js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/posts/[post].css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/posts/[post]/[comment]".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/posts/[post]/[comment].js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/posts/[post]/[comment].css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/posts/custom-post".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/custom-post.js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/custom-post.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest_mock.insert(
|
||||||
|
"/about".to_string(),
|
||||||
|
BundleInfo {
|
||||||
|
file: "assets/about.js".to_string(),
|
||||||
|
|
||||||
|
css: vec!["assets/about.css".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
MANIFEST.get_or_init(|| manifest_mock);
|
||||||
|
let location = Location::from(
|
||||||
|
&uri.unwrap_or("http://localhost:3000/")
|
||||||
|
.parse::<Uri>()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Payload {
|
||||||
|
router: location,
|
||||||
|
props: &None::<Option<()>>,
|
||||||
|
mode,
|
||||||
|
js_bundles: None,
|
||||||
|
css_bundles: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_load_the_bundles_on_mode_prod() {
|
||||||
|
let mut payload = prepare_payload(None, Mode::Prod);
|
||||||
|
|
||||||
|
let _ = payload.client_payload();
|
||||||
|
assert_eq!(
|
||||||
|
payload.js_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.js".to_string(),
|
||||||
|
&"assets/index.js".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.css_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.css".to_string(),
|
||||||
|
&"assets/index.css".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_load_the_bundles_on_mode_dev() {
|
||||||
|
let mut payload = prepare_payload(None, Mode::Dev);
|
||||||
|
let _ = payload.client_payload();
|
||||||
|
assert!(payload.js_bundles.is_none());
|
||||||
|
assert!(payload.css_bundles.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_load_the_correct_single_dyn_path_bundles() {
|
||||||
|
let mut payload = prepare_payload(Some("http://localhost:3000/posts/a-post"), Mode::Prod);
|
||||||
|
let _ = payload.client_payload();
|
||||||
|
assert_eq!(
|
||||||
|
payload.js_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.js".to_string(),
|
||||||
|
&"assets/posts/[post].js".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.css_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.css".to_string(),
|
||||||
|
&"assets/posts/[post].css".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_load_the_correct_nested_dyn_path_bundles() {
|
||||||
|
let mut payload = prepare_payload(
|
||||||
|
Some("http://localhost:3000/posts/a-post/a-comment"),
|
||||||
|
Mode::Prod,
|
||||||
|
);
|
||||||
|
let _ = payload.client_payload();
|
||||||
|
assert_eq!(
|
||||||
|
payload.js_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.js".to_string(),
|
||||||
|
&"assets/posts/[post]/[comment].js".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.css_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.css".to_string(),
|
||||||
|
&"assets/posts/[post]/[comment].css".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_load_the_defined_path_bundles() {
|
||||||
|
let mut payload = prepare_payload(Some("http://localhost:3000/about"), Mode::Prod);
|
||||||
|
let _ = payload.client_payload();
|
||||||
|
assert_eq!(
|
||||||
|
payload.js_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.js".to_string(),
|
||||||
|
&"assets/about.js".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.css_bundles,
|
||||||
|
Some(vec![
|
||||||
|
&"assets/bundled-file.css".to_string(),
|
||||||
|
&"assets/about.css".to_string()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,35 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use axum::http::{HeaderMap, Uri};
|
use axum::http::{HeaderMap, Uri};
|
||||||
|
|
||||||
|
/// Location must match client side interface
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
pub struct Location {
|
||||||
|
href: String,
|
||||||
|
pathname: String,
|
||||||
|
search: HashMap<String, String>,
|
||||||
|
search_str: String,
|
||||||
|
hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Location {
|
||||||
|
pub fn pathname(&self) -> &String {
|
||||||
|
&self.pathname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a Uri> for Location {
|
||||||
|
fn from(uri: &Uri) -> Self {
|
||||||
|
Location {
|
||||||
|
href: uri.to_string(),
|
||||||
|
pathname: uri.path().to_string(),
|
||||||
|
// TODO: handler search map
|
||||||
|
search: HashMap::new(),
|
||||||
|
search_str: uri.query().unwrap_or("").to_string(),
|
||||||
|
hash: "".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Request<'a> {
|
pub struct Request<'a> {
|
||||||
uri: &'a Uri,
|
uri: &'a Uri,
|
||||||
@@ -10,17 +39,6 @@ pub struct Request<'a> {
|
|||||||
pub params: HashMap<String, String>,
|
pub params: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Location must match client side interface
|
|
||||||
#[derive(Serialize, Debug)]
|
|
||||||
pub struct Location<'a> {
|
|
||||||
href: String,
|
|
||||||
pathname: &'a str,
|
|
||||||
search: HashMap<String, String>,
|
|
||||||
search_str: &'a str,
|
|
||||||
/// Server does not need it. Will be hanlder client side
|
|
||||||
hash: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Request<'a> {
|
impl<'a> Request<'a> {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
uri: &'a Uri,
|
uri: &'a Uri,
|
||||||
@@ -34,14 +52,7 @@ impl<'a> Request<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn location(&self) -> Location<'a> {
|
pub fn location(&self) -> Location {
|
||||||
Location {
|
Location::from(self.uri)
|
||||||
href: self.uri.to_string(),
|
|
||||||
pathname: &self.uri.path(),
|
|
||||||
// TODO: handler search map
|
|
||||||
search: HashMap::new(),
|
|
||||||
search_str: &self.uri.query().unwrap_or(""),
|
|
||||||
hash: "",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,98 @@
|
|||||||
|
use crate::Request;
|
||||||
|
use crate::{ssr::Js, Payload};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{Html, IntoResponse, Redirect, Response as AxumResponse};
|
||||||
|
use axum::Json;
|
||||||
use erased_serde::Serialize;
|
use erased_serde::Serialize;
|
||||||
|
|
||||||
|
pub struct Props {
|
||||||
|
data: Box<dyn Serialize>,
|
||||||
|
http_code: StatusCode,
|
||||||
|
}
|
||||||
|
|
||||||
pub enum Response {
|
pub enum Response {
|
||||||
Redirect(String),
|
Redirect(String),
|
||||||
Props(Box<dyn Serialize>),
|
Props(Props),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct JsonResponseInfo {
|
||||||
|
redirect_destination: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonResponseInfo {
|
||||||
|
fn new(redirect_destination: Option<String>) -> JsonResponseInfo {
|
||||||
|
JsonResponseInfo {
|
||||||
|
redirect_destination,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct JsonResponse<'a> {
|
||||||
|
data: Option<&'a dyn Serialize>,
|
||||||
|
info: JsonResponseInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> JsonResponse<'a> {
|
||||||
|
fn new(props: &'a dyn Serialize) -> Self {
|
||||||
|
JsonResponse {
|
||||||
|
data: Some(props),
|
||||||
|
info: JsonResponseInfo::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_redirect(destination: String) -> Self {
|
||||||
|
JsonResponse {
|
||||||
|
data: None,
|
||||||
|
info: JsonResponseInfo::new(Some(destination)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Props {
|
||||||
|
pub fn new(data: impl Serialize + 'static) -> Self {
|
||||||
|
Props {
|
||||||
|
data: Box::new(data),
|
||||||
|
http_code: StatusCode::OK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_status(data: impl Serialize + 'static, http_code: StatusCode) -> Self {
|
||||||
|
Props {
|
||||||
|
data: Box::new(data),
|
||||||
|
http_code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Response {
|
||||||
|
pub fn render_to_string(&self, req: Request) -> AxumResponse {
|
||||||
|
match self {
|
||||||
|
Self::Props(Props { data, http_code }) => {
|
||||||
|
let payload = Payload::new(&req, data).client_payload().unwrap();
|
||||||
|
|
||||||
|
match Js::render_to_string(Some(&payload)) {
|
||||||
|
Ok(html) => (*http_code, Html(html)).into_response(),
|
||||||
|
Err(_) => {
|
||||||
|
(*http_code, Html("500 Internal server error".to_string())).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Redirect(to) => Redirect::permanent(to).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn json(&self) -> impl IntoResponse {
|
||||||
|
match self {
|
||||||
|
Self::Props(Props { data, http_code }) => {
|
||||||
|
(*http_code, Json(JsonResponse::new(data))).into_response()
|
||||||
|
}
|
||||||
|
Self::Redirect(destination) => (
|
||||||
|
StatusCode::PERMANENT_REDIRECT,
|
||||||
|
Json(JsonResponse::new_redirect(destination.to_string())),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use crate::mode::{Mode, GLOBAL_MODE};
|
||||||
|
|
||||||
|
use crate::manifest::load_manifest;
|
||||||
|
use axum::routing::{get, Router};
|
||||||
|
use ssr_rs::Ssr;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
catch_all::catch_all, vite_reverse_proxy::vite_reverse_proxy,
|
||||||
|
vite_websocket_proxy::vite_websocket_proxy,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEV_PUBLIC_DIR: &str = "public";
|
||||||
|
const PROD_PUBLIC_DIR: &str = "out/client";
|
||||||
|
|
||||||
|
pub struct Server {
|
||||||
|
router: Router<reqwest::Client>,
|
||||||
|
mode: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn init(router: Router<reqwest::Client>, mode: Mode) -> Server {
|
||||||
|
Ssr::create_platform();
|
||||||
|
|
||||||
|
GLOBAL_MODE.set(mode).unwrap();
|
||||||
|
|
||||||
|
if mode == Mode::Prod {
|
||||||
|
load_manifest()
|
||||||
|
}
|
||||||
|
|
||||||
|
Server { router, mode }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) {
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||||
|
|
||||||
|
let fetch = reqwest::Client::new();
|
||||||
|
|
||||||
|
if self.mode == Mode::Dev {
|
||||||
|
println!("\nDevelopment app ready at http://localhost:3000/");
|
||||||
|
let router = self
|
||||||
|
.router
|
||||||
|
.to_owned()
|
||||||
|
.route("/vite-server/", get(vite_websocket_proxy))
|
||||||
|
.route("/vite-server/*path", get(vite_reverse_proxy))
|
||||||
|
.fallback_service(ServeDir::new(DEV_PUBLIC_DIR).fallback(get(catch_all)))
|
||||||
|
.with_state(fetch);
|
||||||
|
|
||||||
|
axum::serve(listener, router)
|
||||||
|
.await
|
||||||
|
.expect("Failed to serve development server");
|
||||||
|
} else {
|
||||||
|
println!("\nProduction app ready at http://localhost:3000/");
|
||||||
|
let router = self
|
||||||
|
.router
|
||||||
|
.to_owned()
|
||||||
|
.fallback_service(ServeDir::new(PROD_PUBLIC_DIR).fallback(get(catch_all)))
|
||||||
|
.with_state(fetch);
|
||||||
|
|
||||||
|
axum::serve(listener, router)
|
||||||
|
.await
|
||||||
|
.expect("Failed to serve production server");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,48 @@
|
|||||||
|
use crate::mode::{Mode, GLOBAL_MODE};
|
||||||
use ssr_rs::Ssr;
|
use ssr_rs::Ssr;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::fs::read_to_string;
|
use std::fs::read_to_string;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// For the server side rendering we need to split the implementation between dev and prod.
|
||||||
|
/// This completely remove the multi-thread optimization on dev but allow the dev server to
|
||||||
|
/// update the SSR result without reloading the whole server.
|
||||||
pub struct Js;
|
pub struct Js;
|
||||||
|
|
||||||
impl Js {
|
impl Js {
|
||||||
|
pub fn render_to_string(payload: Option<&str>) -> Result<String, &'static str> {
|
||||||
|
let mode = GLOBAL_MODE.get().expect("Failed to get GLOBAL_MODE");
|
||||||
|
|
||||||
|
if *mode == Mode::Dev {
|
||||||
|
DevJs::render_to_string(payload)
|
||||||
|
} else {
|
||||||
|
ProdJs::SSR.with(|ssr| ssr.borrow_mut().render_to_string(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProdJs;
|
||||||
|
|
||||||
|
impl ProdJs {
|
||||||
thread_local! {
|
thread_local! {
|
||||||
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
|
pub static SSR: RefCell<Ssr<'static, 'static>> = RefCell::new(
|
||||||
// TODO: handle here dev/prod source
|
|
||||||
Ssr::from(
|
Ssr::from(
|
||||||
read_to_string("./.tuono/server/dev-server.js").unwrap(),
|
read_to_string(PathBuf::from("./out/server/prod-server.js")).expect("Server bundle not found"), ""
|
||||||
""
|
|
||||||
).unwrap()
|
).unwrap()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DevJs;
|
||||||
|
|
||||||
|
impl DevJs {
|
||||||
|
pub fn render_to_string(params: Option<&str>) -> Result<String, &'static str> {
|
||||||
|
Ssr::from(
|
||||||
|
read_to_string(PathBuf::from("./.tuono/server/dev-server.js"))
|
||||||
|
.expect("Server bundle not found"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.render_to_string(params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
|
||||||
|
use axum::http::{HeaderName, HeaderValue};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
const VITE_URL: &str = "http://localhost:3001/vite-server";
|
||||||
|
|
||||||
|
pub async fn vite_reverse_proxy(
|
||||||
|
State(client): State<Client>,
|
||||||
|
Path(path): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match client.get(format!("{VITE_URL}/{path}")).send().await {
|
||||||
|
Ok(res) => {
|
||||||
|
let mut response_builder = Response::builder().status(res.status().as_u16());
|
||||||
|
|
||||||
|
{
|
||||||
|
let headers = response_builder.headers_mut().unwrap();
|
||||||
|
res.headers().into_iter().for_each(|(name, value)| {
|
||||||
|
let name = HeaderName::from_bytes(name.as_ref()).unwrap();
|
||||||
|
let value = HeaderValue::from_bytes(value.as_ref()).unwrap();
|
||||||
|
headers.insert(name, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
response_builder
|
||||||
|
.body(Body::from_stream(res.bytes_stream()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
Err(_) => todo!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
use axum::extract::ws::{self, WebSocket, WebSocketUpgrade};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio_tungstenite::connect_async;
|
||||||
|
use tokio_tungstenite::tungstenite::{Error, Message};
|
||||||
|
use tungstenite::client::IntoClientRequest;
|
||||||
|
use tungstenite::ClientRequestBuilder;
|
||||||
|
|
||||||
|
const VITE_WS: &str = "ws://localhost:3001/vite-server/";
|
||||||
|
const VITE_WS_PROTOCOL: &str = "vite-hmr";
|
||||||
|
|
||||||
|
/// This is the entry point to proxy the vite's WebSocket.
|
||||||
|
/// The proxy is needed for allowing all the development requests to point
|
||||||
|
/// to localhost:3000/*. This enabled the framework to be developed in a Docker
|
||||||
|
/// environment with just the 3000 port exposed.
|
||||||
|
pub async fn vite_websocket_proxy(ws: WebSocketUpgrade) -> impl IntoResponse {
|
||||||
|
ws.protocols([VITE_WS_PROTOCOL]).on_upgrade(handle_socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(mut tuono_socket: WebSocket) {
|
||||||
|
// Send back a message to confirm connection
|
||||||
|
if tuono_socket
|
||||||
|
.send(ws::Message::Ping("tuono connected".into()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
// If is error close the connection
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let vite_ws_request = ClientRequestBuilder::new(VITE_WS.parse().unwrap())
|
||||||
|
.with_sub_protocol(VITE_WS_PROTOCOL)
|
||||||
|
.into_client_request()
|
||||||
|
.expect("Failed to create vite WS request");
|
||||||
|
|
||||||
|
let vite_socket = match connect_async(vite_ws_request).await {
|
||||||
|
Ok((stream, _)) => {
|
||||||
|
// Connected to vite's WebSocket
|
||||||
|
stream
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to connect to vite's WebSocket. Error: {e}");
|
||||||
|
// As fallback vite automatically connect to port 3001.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut vite_sender, mut vite_receiver) = vite_socket.split();
|
||||||
|
let (mut tuono_sender, mut tuono_receiver) = tuono_socket.split();
|
||||||
|
|
||||||
|
// Handle browser messages.
|
||||||
|
// Every message gets forwarded to the vite WebSocket
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(msg) = tuono_receiver.next().await {
|
||||||
|
if let Ok(msg) = msg {
|
||||||
|
let msg_to_vite = match msg.clone() {
|
||||||
|
ws::Message::Text(str) => Message::Text(str),
|
||||||
|
ws::Message::Pong(payload) => Message::Pong(payload),
|
||||||
|
ws::Message::Ping(payload) => Message::Ping(payload),
|
||||||
|
ws::Message::Binary(payload) => Message::Binary(payload),
|
||||||
|
// Hard to match axum and tungstenite close payload.
|
||||||
|
// Not a priority
|
||||||
|
ws::Message::Close(_) => Message::Close(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
vite_sender
|
||||||
|
.send(msg_to_vite)
|
||||||
|
.await
|
||||||
|
.expect("Failed to tunnel msg to vite's WebSocket");
|
||||||
|
|
||||||
|
msg
|
||||||
|
} else {
|
||||||
|
// Close browser's WebSocket connection.
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle vite messages.
|
||||||
|
// Every message gets forwarded to the browser.
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(Ok(msg)) = vite_receiver.next().await {
|
||||||
|
let msg_to_browser = match msg {
|
||||||
|
Message::Text(str) => ws::Message::Text(str),
|
||||||
|
Message::Ping(payload) => ws::Message::Ping(payload),
|
||||||
|
Message::Pong(payload) => ws::Message::Pong(payload),
|
||||||
|
Message::Binary(payload) => ws::Message::Binary(payload),
|
||||||
|
// Hard to match axum and tungstenite close payload.
|
||||||
|
// Not a priority
|
||||||
|
Message::Close(_) => ws::Message::Close(None),
|
||||||
|
_ => {
|
||||||
|
eprintln!("Unexpected message from the vite WebSocket to the browser: {msg:?}");
|
||||||
|
ws::Message::Text("Unhandled".to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = tuono_sender.send(msg_to_browser).await {
|
||||||
|
if err.to_string() != Error::AlreadyClosed.to_string() {
|
||||||
|
eprintln!("Failed to send back message from vite to browser: {err}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tuono_lib_macros"
|
name = "tuono_lib_macros"
|
||||||
version = "0.1.2"
|
version = "0.4.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "The react/rust fullstack framework"
|
description = "The react/rust fullstack framework"
|
||||||
homepage = "https://github.com/Valerioageno/tuono"
|
repository = "https://github.com/Valerioageno/tuono"
|
||||||
readme = "../../README.md"
|
readme = "../../README.md"
|
||||||
license-file = "../../LICENSE.md"
|
license-file = "../../LICENSE.md"
|
||||||
categories = ["web-programming"]
|
categories = ["web-programming"]
|
||||||
|
|||||||
@@ -8,59 +8,36 @@ pub fn handler_core(_args: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
let fn_name = item.clone().sig.ident;
|
let fn_name = item.clone().sig.ident;
|
||||||
|
|
||||||
quote! {
|
quote! {
|
||||||
use axum::response::{Html, IntoResponse};
|
use tuono_lib::axum::response::IntoResponse;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use axum::extract::{State, Path};
|
use tuono_lib::axum::extract::{State, Path};
|
||||||
use reqwest::Client;
|
|
||||||
|
|
||||||
#item
|
#item
|
||||||
|
|
||||||
pub async fn route(
|
pub async fn route(
|
||||||
Path(params): Path<HashMap<String, String>>,
|
Path(params): Path<HashMap<String, String>>,
|
||||||
State(client): State<Client>,
|
State(client): State<tuono_lib::reqwest::Client>,
|
||||||
request: axum::extract::Request
|
request: tuono_lib::axum::extract::Request
|
||||||
) -> Html<String> {
|
) -> impl IntoResponse {
|
||||||
let pathname = &request.uri();
|
let pathname = &request.uri();
|
||||||
let headers = &request.headers();
|
let headers = &request.headers();
|
||||||
|
|
||||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||||
|
|
||||||
let local_response = #fn_name(req.clone(), client).await;
|
#fn_name(req.clone(), client).await.render_to_string(req)
|
||||||
|
|
||||||
let res = match local_response {
|
|
||||||
tuono_lib::Response::Props(val) => {
|
|
||||||
|
|
||||||
// TODO: remove unwrap
|
|
||||||
let payload = tuono_lib::Payload::new(&req, val).client_payload().unwrap();
|
|
||||||
|
|
||||||
tuono_lib::ssr::Js::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload)))
|
|
||||||
},
|
|
||||||
/// TODO: handle here redirection and rewrite
|
|
||||||
_ => Ok("500 Internal server error".to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
match res {
|
|
||||||
Ok(html) => Html(html),
|
|
||||||
_ => Html("500 internal server error".to_string())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn api(
|
pub async fn api(
|
||||||
Path(params): Path<HashMap<String, String>>,
|
Path(params): Path<HashMap<String, String>>,
|
||||||
State(client): State<Client>,
|
State(client): State<tuono_lib::reqwest::Client>,
|
||||||
request: axum::extract::Request
|
request: tuono_lib::axum::extract::Request
|
||||||
) -> axum::response::Response {
|
) -> impl IntoResponse{
|
||||||
let pathname = &request.uri();
|
let pathname = &request.uri();
|
||||||
let headers = &request.headers();
|
let headers = &request.headers();
|
||||||
|
|
||||||
let req = tuono_lib::Request::new(pathname, headers, params);
|
let req = tuono_lib::Request::new(pathname, headers, params);
|
||||||
|
|
||||||
let local_response = #fn_name(req.clone(), client).await;
|
#fn_name(req.clone(), client).await.json()
|
||||||
|
|
||||||
let res = match local_response{
|
|
||||||
tuono_lib::Response::Props(val) => return axum::Json(val).into_response(),
|
|
||||||
_ => return axum::Json("").into_response()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.into()
|
.into()
|
||||||
|
|||||||
@@ -7,5 +7,3 @@ mod handler;
|
|||||||
pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
pub fn handler(args: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
handler::handler_core(args, item)
|
handler::handler_core(args, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+195
-31
@@ -4,9 +4,30 @@ This tutorial is meant for giving you a sneak peek of the framework and is inten
|
|||||||
|
|
||||||
The first part is about the project setup and the base knowledge needed to work with tuono. The actual tutorial starts at [Tutorial introduction](#tutorial-introduction).
|
The first part is about the project setup and the base knowledge needed to work with tuono. The actual tutorial starts at [Tutorial introduction](#tutorial-introduction).
|
||||||
|
|
||||||
> If you prefer to see directly the final outcome check out the [tutorial source](https://github.com/Valerioageno/tuono/tree/main/examples/tutorial).
|
> I'd love to hear your thoughts about the framework and the tutorial - feel free to reach me
|
||||||
|
at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or on Twitter (X) DMs
|
||||||
|
[@valerioageno](https://twitter.com/valerioageno)
|
||||||
|
|
||||||
## Installation
|
This tutorial is **not meant** for people that don't know React - in that case I suggest you to first read the [React doc](https://react.dev/);
|
||||||
|
|
||||||
|
Typescript and Rust knowledge is not a requirement though!
|
||||||
|
|
||||||
|
## Table of Content
|
||||||
|
|
||||||
|
* [CLI Installation](#cli-installation)
|
||||||
|
* [Project scaffold](#project-scaffold)
|
||||||
|
* [Start the dev environment](#start-the-dev-environment)
|
||||||
|
* [The “/” route](#the--route)
|
||||||
|
* [Tutorial introduction](#tutorial-introduction)
|
||||||
|
* [Fetch all the pokemons](#fetch-all-the-pokemons)
|
||||||
|
* [Create a stand-alone component](#create-a-stand-alone-component)
|
||||||
|
* [Create the /pokemons/[pokemon] route](#create-the-pokemonspokemon-route)
|
||||||
|
* [Error handling](#error-handling)
|
||||||
|
* [Handle redirections](#handle-redirections)
|
||||||
|
* [Building for production](#building-for-production)
|
||||||
|
* [Conclusion](#conclusion)
|
||||||
|
|
||||||
|
## CLI Installation
|
||||||
|
|
||||||
The tuono CLI is hosted on [crates.io](https://crates.io/crates/tuono); to download and install it just run on a terminal:
|
The tuono CLI is hosted on [crates.io](https://crates.io/crates/tuono); to download and install it just run on a terminal:
|
||||||
|
|
||||||
@@ -20,9 +41,11 @@ To check that is correctly installed run:
|
|||||||
$ tuono --version
|
$ tuono --version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run `tuono -h` to see all the available commands.
|
||||||
|
|
||||||
## Project scaffold
|
## Project scaffold
|
||||||
|
|
||||||
To setup a new project you just need to run the following command:
|
To setup a new fresh project you just need to run the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ tuono new tuono-tutorial
|
$ tuono new tuono-tutorial
|
||||||
@@ -31,22 +54,22 @@ $ tuono new tuono-tutorial
|
|||||||
Get into the project folder and install the dependencies with:
|
Get into the project folder and install the dependencies with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ pnpm install
|
$ npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Open it with your favourite code editor.
|
Open it with your favourite code editor.
|
||||||
|
|
||||||
The project will have the following structure:
|
The project will have the following structure:
|
||||||
|
|
||||||
```bash
|
```
|
||||||
| public/
|
├── package.json
|
||||||
- src/
|
├── public
|
||||||
| routes/
|
├── src
|
||||||
| styles/
|
│ ├── routes
|
||||||
| package.json
|
│ └── styles
|
||||||
| Cargo.toml
|
├── Cargo.toml
|
||||||
| .gitignore
|
├── README.md
|
||||||
| tsconfig.json
|
└── tsconfig.json
|
||||||
```
|
```
|
||||||
|
|
||||||
**public/**: put here all the files you want to be public
|
**public/**: put here all the files you want to be public
|
||||||
@@ -75,8 +98,8 @@ All the `index.tsx` files represent the folder root page (i.e. `src/routes/posts
|
|||||||
The file `index.rs` represents the server side capabilities for the index route. On this file you can:
|
The file `index.rs` represents the server side capabilities for the index route. On this file you can:
|
||||||
|
|
||||||
- Passing server side props
|
- Passing server side props
|
||||||
- Redirect/Rewrite to a different route (Available soon)
|
- Changing http status code
|
||||||
- Changing http status code (Available soon)
|
- Redirecting to a different route
|
||||||
|
|
||||||
## Tutorial introduction
|
## Tutorial introduction
|
||||||
|
|
||||||
@@ -84,6 +107,9 @@ Now that we have some knowledge about the project structure let’s start the re
|
|||||||
|
|
||||||
The goal is to use the [PokeAPI](https://pokeapi.co/docs/v2) to list all the pokemons of the first generation (the best one btw) and then reserve a dynamic page for each one separately.
|
The goal is to use the [PokeAPI](https://pokeapi.co/docs/v2) to list all the pokemons of the first generation (the best one btw) and then reserve a dynamic page for each one separately.
|
||||||
|
|
||||||
|
> If you have already installed the tuono CLI and you prefer read the code instead of writing it yourself
|
||||||
|
you can download the tutorial source with `tuono new tuono-tutorial --template tutorial`
|
||||||
|
|
||||||
## Fetch all the pokemons
|
## Fetch all the pokemons
|
||||||
|
|
||||||
To start let’s fetch all of them in the root page; since we want to render them on the server side we gonna need to implement the logic in the `index.rs` file.
|
To start let’s fetch all of them in the root page; since we want to render them on the server side we gonna need to implement the logic in the `index.rs` file.
|
||||||
@@ -93,7 +119,7 @@ Clear the `index.rs` file and paste:
|
|||||||
```rust
|
```rust
|
||||||
// src/routes/index.rs
|
// src/routes/index.rs
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tuono_lib::{Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||||
|
|
||||||
@@ -110,15 +136,13 @@ struct Pokemon {
|
|||||||
|
|
||||||
#[tuono_lib::handler]
|
#[tuono_lib::handler]
|
||||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||||
|
|
||||||
return match fetch.get(ALL_POKEMON).send().await {
|
return match fetch.get(ALL_POKEMON).send().await {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
let data = res.json::<Pokemons>().await.unwrap();
|
let data = res.json::<Pokemons>().await.unwrap();
|
||||||
Response::Props(Box::new(data))
|
Response::Props(Props::new(data))
|
||||||
}
|
}
|
||||||
Err(_err) => Response::Props(Box::new(Pokemons { results: vec![] })),
|
Err(_err) => Response::Props(Props::new("{}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -260,7 +284,7 @@ Create alongside the `PokemonLink.tsx` component the CSS module `PokemonLink.mod
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 💡 SASS is supported out of the box. Just install the processor in the devDependencies `pnpm i -D sass` and run again `tuono dev`
|
> 💡 SASS is supported out of the box. Just install the processor in the devDependencies `npm i -D sass` and run again `tuono dev`
|
||||||
|
|
||||||
Then import the styles into the `PokemonLink` component as following:
|
Then import the styles into the `PokemonLink` component as following:
|
||||||
|
|
||||||
@@ -302,8 +326,9 @@ These two will handle every requests that points to `http://localhost:3000/pokem
|
|||||||
Let’s first work on the server side file. Paste into the new `[pokemon].rs` file the following code:
|
Let’s first work on the server side file. Paste into the new `[pokemon].rs` file the following code:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
// src/routes/pokemons/[pokemon].rs
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tuono_lib::{Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||||
|
|
||||||
@@ -323,16 +348,10 @@ async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
|||||||
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
let data = res.json::<Pokemon>().await.unwrap();
|
let data = res.json::<Pokemon>().await.unwrap();
|
||||||
Response::Props(Box::new(data))
|
Response::Props(Props::new(data))
|
||||||
}
|
}
|
||||||
Err(_err) => Response::Props(Box::new(Pokemon {
|
Err(_err) => Response::Props(Props::new("{}"))
|
||||||
name: "Nope".to_string(),
|
|
||||||
id: 0,
|
|
||||||
weight: 0,
|
|
||||||
height: 0,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -438,9 +457,154 @@ export default function PokemonView({
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
With the current setup all the routes always return a `200 Success` http status no matter the response type.
|
||||||
|
|
||||||
|
In order to return a more meaningful status code to the browser the `Props` struct can be initialized with also the
|
||||||
|
`Props::new_with_status()` method.
|
||||||
|
|
||||||
|
Let's see how it works!
|
||||||
|
|
||||||
|
```diff
|
||||||
|
// src/routes/pokemons/[pokemon].rs
|
||||||
|
++ use reqwest::StatusCode;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
|
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct Pokemon {
|
||||||
|
name: String,
|
||||||
|
id: u16,
|
||||||
|
weight: u16,
|
||||||
|
height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tuono_lib::handler]
|
||||||
|
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||||
|
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||||
|
let pokemon = req.params.get("pokemon").unwrap();
|
||||||
|
|
||||||
|
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||||
|
Ok(res) => {
|
||||||
|
++ if res.status() == StatusCode::NOT_FOUND {
|
||||||
|
++ return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
|
||||||
|
++ }
|
||||||
|
|
||||||
|
let data = res.json::<Pokemon>().await.unwrap();
|
||||||
|
Response::Props(Props::new(data))
|
||||||
|
}
|
||||||
|
-- Err(_err) => Response::Props(Props::new(
|
||||||
|
++ Err(_err) => Response::Props(Props::new_with_status(
|
||||||
|
++ "{}",
|
||||||
|
++ StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
// src/routes/index.rs
|
||||||
|
++ use reqwest::StatusCode;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
|
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct Pokemons {
|
||||||
|
results: Vec<Pokemon>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct Pokemon {
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tuono_lib::handler]
|
||||||
|
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
||||||
|
return match fetch.get(ALL_POKEMON).send().await {
|
||||||
|
Ok(res) => {
|
||||||
|
let data = res.json::<Pokemons>().await.unwrap();
|
||||||
|
Response::Props(Props::new(data))
|
||||||
|
}
|
||||||
|
-- Err(_err) => Response::Props(Props::new(
|
||||||
|
++ Err(_err) => Response::Props(Props::new_with_status(
|
||||||
|
++ "{}", // Return empty JSON
|
||||||
|
++ StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you now try to load a not existing pokemon (`http://localhost:3000/pokemons/tuono-pokemon`) you will
|
||||||
|
correctly receive a 404 status code in the console.
|
||||||
|
|
||||||
|
## Handle redirections
|
||||||
|
|
||||||
|
What if there is a pokemon among all of them that should be considered the GOAT? What
|
||||||
|
we are going to do right now is creating a new route `/pokemons/GOAT` that points to the best
|
||||||
|
pokemon of the first generation.
|
||||||
|
|
||||||
|
First let's create a new route by just creating an new file `/pokemons/GOAT.rs` and pasting the following code:
|
||||||
|
|
||||||
|
```rs
|
||||||
|
// src/routes/pokemons/GOAT.rs
|
||||||
|
use tuono_lib::{Request, Response};
|
||||||
|
|
||||||
|
#[tuono_lib::handler]
|
||||||
|
async fn redirect_to_goat(_: Request<'_>, _: reqwest::Client) -> Response {
|
||||||
|
// Of course the GOAT is mewtwo - feel free to select your favourite 😉
|
||||||
|
Response::Redirect("/pokemons/mewtwo".to_string())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now let's create the button in the home page to actually point to it!
|
||||||
|
|
||||||
|
```diff
|
||||||
|
// src/routes/index.tsx
|
||||||
|
|
||||||
|
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
||||||
|
++ <PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
|
||||||
|
{data.results.map((pokemon, i) => {
|
||||||
|
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
Now at [http://localhost:3000/](http:/localhost:3000/) you will find a new link at the beginning of the list.
|
||||||
|
Click on it and see the application automatically redirecting you to your favourite pokemon's route!
|
||||||
|
|
||||||
|
## Building for production
|
||||||
|
|
||||||
|
The source now is ready to be released. Both server and client have been managed in a unoptimized way
|
||||||
|
to easy the development experience. To build the project to the production state just run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ tuono build
|
||||||
|
```
|
||||||
|
|
||||||
|
This command just created the final assets within the `out` directory. To run then the prodiction server
|
||||||
|
run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Check again [`http://localhost:3000/`](http://localhost:3000/) - This environment now has all the
|
||||||
|
optimizations ready to unleash the power of a rust server that seamessly renders a React application!🚀
|
||||||
|
|
||||||
|
> Note: The `out` directory is not standalone. You can't rely just on it to run the production server.
|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|
||||||
That’s it! You just created a multi thread full stack application with rust and react.
|
That’s it! You just created a multi thread full stack application with rust and react.
|
||||||
|
|
||||||
The project is still under heavy development and the script for the production build (`tuono build`) is not ready yet but
|
The project is still under heavy development and many features are not ready yet but
|
||||||
I hope you got the taste of what is like working with rust and react in the same stack.
|
I hope you got the taste of what is like working with rust and react in the same stack.
|
||||||
|
|
||||||
|
As I mentioned in the introduction I'd love to hear what you thought about the framework and the tutorial - feel free to reach me
|
||||||
|
at [valerioageno@yahoo.it](mailto:valerioageno@yahoo.it) or in Twitter (X) DMs [@valerioageno](https://twitter.com/valerioageno).
|
||||||
|
|||||||
@@ -7,3 +7,13 @@ To simply scaffold the base project run in your terminal:
|
|||||||
```shell
|
```shell
|
||||||
$ tuono new [NAME]
|
$ tuono new [NAME]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can install any example included in this folder by just using the `--template` flag:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ tuono new [NAME] --template [TEMPLATE]
|
||||||
|
```
|
||||||
|
|
||||||
|
`[TEMPLATE]` is the folder name.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,5 @@ name = "tuono"
|
|||||||
path = ".tuono/main.rs"
|
path = ".tuono/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = {version = "0.7.5", features = ["json"]}
|
|
||||||
tokio = { version = "1.37.0", features = ["full"] }
|
|
||||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
|
||||||
serde = { version = "1.0.202", features = ["derive"] }
|
|
||||||
tuono_lib = { path = "../../crates/tuono_lib/"}
|
tuono_lib = { path = "../../crates/tuono_lib/"}
|
||||||
serde_json = "1.0"
|
|
||||||
reqwest = {version = "0.12.4", features = ["json"]}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tuono_lib::{Request, Response};
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct MyResponse<'a> {
|
struct MyResponse<'a> {
|
||||||
@@ -8,7 +8,7 @@ struct MyResponse<'a> {
|
|||||||
|
|
||||||
#[tuono_lib::handler]
|
#[tuono_lib::handler]
|
||||||
async fn get_server_side_props(_req: Request<'_>, _fetch: reqwest::Client) -> Response {
|
async fn get_server_side_props(_req: Request<'_>, _fetch: reqwest::Client) -> Response {
|
||||||
Response::Props(Box::new(MyResponse {
|
Response::Props(Props::new(MyResponse {
|
||||||
subtitle: "The react / rust fullstack framework",
|
subtitle: "The react / rust fullstack framework",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ name = "tuono"
|
|||||||
path = ".tuono/main.rs"
|
path = ".tuono/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = {version = "0.7.5", features = ["json"]}
|
|
||||||
tokio = { version = "1.37.0", features = ["full"] }
|
|
||||||
tower-http = {version = "0.5.2", features = ["fs"]}
|
|
||||||
serde = { version = "1.0.202", features = ["derive"] }
|
|
||||||
tuono_lib = { path = "../../crates/tuono_lib/"}
|
tuono_lib = { path = "../../crates/tuono_lib/"}
|
||||||
serde_json = "1.0"
|
serde = { version = "1.0.202", features = ["derive"] }
|
||||||
reqwest = {version = "0.12.4", features = ["json"]}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
# Tuono tutorial
|
# Tuono tutorial
|
||||||
|
|
||||||
This project is the outcome of the [tuono tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md)
|
This project is the outcome of the [tuono tutorial](https://github.com/Valerioageno/tuono/blob/main/docs/tutorial.md)
|
||||||
|
|
||||||
|
If you want to directly install it you can run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ tuono new my-project -t tutorial
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/routes/index.rs
|
// src/routes/index.rs
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tuono_lib::{Request, Response};
|
use tuono_lib::reqwest::{Client, StatusCode};
|
||||||
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
|
||||||
|
|
||||||
@@ -16,12 +17,15 @@ struct Pokemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tuono_lib::handler]
|
#[tuono_lib::handler]
|
||||||
async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
|
async fn get_all_pokemons(_req: Request<'_>, fetch: Client) -> Response {
|
||||||
return match fetch.get(ALL_POKEMON).send().await {
|
match fetch.get(ALL_POKEMON).send().await {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
let data = res.json::<Pokemons>().await.unwrap();
|
let data = res.json::<Pokemons>().await.unwrap();
|
||||||
Response::Props(Box::new(data))
|
Response::Props(Props::new(data))
|
||||||
|
}
|
||||||
|
Err(_err) => Response::Props(Props::new_with_status(
|
||||||
|
"{}", // Return empty JSON
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
Err(_err) => Response::Props(Box::new(Pokemons { results: vec![] })),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default function IndexPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
<ul style={{ flexWrap: 'wrap', display: 'flex', gap: 10 }}>
|
||||||
|
<PokemonLink pokemon={{ name: 'GOAT' }} id={0} />
|
||||||
{data.results.map((pokemon, i) => {
|
{data.results.map((pokemon, i) => {
|
||||||
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
|
return <PokemonLink pokemon={pokemon} id={i + 1} key={i} />
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// src/routes/pokemons/GOAT.rs
|
||||||
|
use tuono_lib::{reqwest::Client, Request, Response};
|
||||||
|
|
||||||
|
#[tuono_lib::handler]
|
||||||
|
async fn redirect_to_goat(_: Request<'_>, _: Client) -> Response {
|
||||||
|
Response::Redirect("/pokemons/mewtwo".to_string())
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
// src/routes/pokemons/[pokemon].rs
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tuono_lib::{Request, Response};
|
use tuono_lib::reqwest::{Client, StatusCode};
|
||||||
|
use tuono_lib::{Props, Request, Response};
|
||||||
|
|
||||||
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
|
||||||
|
|
||||||
@@ -12,20 +14,21 @@ struct Pokemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tuono_lib::handler]
|
#[tuono_lib::handler]
|
||||||
async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
|
async fn get_pokemon(req: Request<'_>, fetch: Client) -> Response {
|
||||||
// The param `pokemon` is defined in the route filename [pokemon].rs
|
// The param `pokemon` is defined in the route filename [pokemon].rs
|
||||||
let pokemon = req.params.get("pokemon").unwrap();
|
let pokemon = req.params.get("pokemon").unwrap();
|
||||||
|
|
||||||
return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
|
if res.status() == StatusCode::NOT_FOUND {
|
||||||
|
return Response::Props(Props::new_with_status("{}", StatusCode::NOT_FOUND));
|
||||||
|
}
|
||||||
let data = res.json::<Pokemon>().await.unwrap();
|
let data = res.json::<Pokemon>().await.unwrap();
|
||||||
Response::Props(Box::new(data))
|
Response::Props(Props::new(data))
|
||||||
|
}
|
||||||
|
Err(_err) => Response::Props(Props::new_with_status(
|
||||||
|
"{}",
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
Err(_err) => Response::Props(Box::new(Pokemon {
|
|
||||||
name: "Nope".to_string(),
|
|
||||||
id: 0,
|
|
||||||
weight: 0,
|
|
||||||
height: 0,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { TuonoProps } from 'tuono'
|
import type { TuonoProps } from 'tuono'
|
||||||
import PokemonView from '../../components/PokemonView'
|
import PokemonView from '../../components/PokemonView'
|
||||||
|
|
||||||
export default function Pokemon({ data }: TuonoProps): JSX.Element {
|
interface Pokemon {
|
||||||
|
name: string
|
||||||
|
id: string
|
||||||
|
weight: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PokemonPage({
|
||||||
|
data,
|
||||||
|
}: TuonoProps<Pokemon>): JSX.Element {
|
||||||
return <PokemonView pokemon={data} />
|
return <PokemonView pokemon={data} />
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -1,19 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "tuono",
|
"name": "workspace",
|
||||||
"version": "0.1.2",
|
|
||||||
"description": "",
|
|
||||||
"main": "src/index.js",
|
|
||||||
"packageManager": "pnpm@9.1.1",
|
"packageManager": "pnpm@9.1.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev --filter tuono",
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"format": "turbo format",
|
"format": "turbo format",
|
||||||
"test": "turbo test"
|
"format:check": "turbo format:check",
|
||||||
|
"types": "turbo types",
|
||||||
|
"test": "turbo test",
|
||||||
|
"test:watch": "turbo test:watch"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"workspaces": [
|
||||||
"type": "module",
|
"tuono",
|
||||||
"author": "",
|
"tuono-lazy-fn-vite-plugin"
|
||||||
|
],
|
||||||
|
"author": "Valerio Ageno",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/config": "^0.7.0",
|
"@tanstack/config": "^0.7.0",
|
||||||
@@ -34,6 +36,6 @@
|
|||||||
"vitest": "^1.5.2"
|
"vitest": "^1.5.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"turbo": "^2.0.3"
|
"turbo": "^2.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
dist/
|
dist
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# tuono-lazy-fn-vite-plugin
|
||||||
|
|
||||||
|
This is a vite plugin for [tuono](https://github.com/Valerioageno/tuono).
|
||||||
|
|
||||||
|
This package specifically handles the transpiling of the `dynamic` function
|
||||||
|
allowing custom componenents to be lazy loaded but also server side rendered.
|
||||||
|
|
||||||
|
Check [tuono](https://github.com/Valerioageno/tuono) for more.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "tuono-lazy-fn-vite-plugin",
|
||||||
|
"version": "0.4.1",
|
||||||
|
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite build --watch",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
|
||||||
|
"format": "prettier -u --write --ignore-unknown '**/*'",
|
||||||
|
"format:check": "prettier --check --ignore-unknown '**/*'",
|
||||||
|
"types": "tsc --noEmit",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Valerio Ageno",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"main": "dist/cjs/index.cjs",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"src",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": {
|
||||||
|
"types": "./dist/esm/index.d.ts",
|
||||||
|
"default": "./dist/esm/index.js"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/cjs/index.d.cts",
|
||||||
|
"default": "./dist/cjs/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.24.4",
|
||||||
|
"@babel/types": "^7.24.0",
|
||||||
|
"vite": "^5.2.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tanstack/config": "^0.7.11",
|
||||||
|
"@types/babel__core": "^7.20.5",
|
||||||
|
"prettier": "^3.2.4",
|
||||||
|
"vitest": "^1.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const TUONO_DYNAMIC_FN_ID = 'dynamic'
|
||||||
|
export const REACT_LAZY_FN_ID = 'lazy'
|
||||||
|
export const TUONO_MAIN_PACKAGE = 'tuono'
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Plugin } from 'vite'
|
||||||
|
import * as babel from '@babel/core'
|
||||||
|
import type { PluginItem } from '@babel/core'
|
||||||
|
|
||||||
|
import {
|
||||||
|
TUONO_MAIN_PACKAGE,
|
||||||
|
TUONO_DYNAMIC_FN_ID,
|
||||||
|
REACT_LAZY_FN_ID,
|
||||||
|
} from './constants'
|
||||||
|
|
||||||
|
import * as t from '@babel/types'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Identifier,
|
||||||
|
ImportDeclaration,
|
||||||
|
CallExpression,
|
||||||
|
ArrowFunctionExpression,
|
||||||
|
StringLiteral,
|
||||||
|
} from '@babel/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This plugin just removes the `dynamic` imported function from any tuono import
|
||||||
|
*/
|
||||||
|
const RemoveTuonoLazyImport: PluginItem = {
|
||||||
|
name: 'remove-tuono-lazy-import-plugin',
|
||||||
|
visitor: {
|
||||||
|
ImportSpecifier: (path) => {
|
||||||
|
if ((path.node.imported as Identifier).name === TUONO_DYNAMIC_FN_ID) {
|
||||||
|
if (
|
||||||
|
(path.parentPath.node as ImportDeclaration).source.value ===
|
||||||
|
TUONO_MAIN_PACKAGE
|
||||||
|
) {
|
||||||
|
path.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This plugin adds: "Import { lazy } from 'react'"
|
||||||
|
* and translate dynamic call into a React.lazy call
|
||||||
|
*/
|
||||||
|
const ImportReactLazy: PluginItem = {
|
||||||
|
name: 'import-react-lazy-plugin',
|
||||||
|
visitor: {
|
||||||
|
// Add the import statement
|
||||||
|
Program: (path: any) => {
|
||||||
|
let isReactImported = false
|
||||||
|
|
||||||
|
path.node.body.forEach((val: any) => {
|
||||||
|
if (val.type === 'ImportDeclaration' && val.source.value === 'react') {
|
||||||
|
isReactImported = true
|
||||||
|
// TODO: Handle also here case of already imported react
|
||||||
|
// Right now works just for the main routes file
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (!isReactImported) {
|
||||||
|
const importDeclaration = t.importDeclaration(
|
||||||
|
[
|
||||||
|
t.importSpecifier(
|
||||||
|
t.identifier(REACT_LAZY_FN_ID),
|
||||||
|
t.identifier(REACT_LAZY_FN_ID),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
t.stringLiteral('react'),
|
||||||
|
)
|
||||||
|
path.unshiftContainer('body', importDeclaration)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Update lazy function name from `dynamic` to `lazy`
|
||||||
|
CallExpression: (path: any) => {
|
||||||
|
if (path.node.callee?.name === TUONO_DYNAMIC_FN_ID) {
|
||||||
|
path.node.callee.name = REACT_LAZY_FN_ID
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the server side we need to statically import the lazy loaded components
|
||||||
|
*/
|
||||||
|
const TurnLazyIntoStaticImport: PluginItem = {
|
||||||
|
name: 'turn-lazy-into-static-import-plugin',
|
||||||
|
visitor: {
|
||||||
|
VariableDeclaration: (path) => {
|
||||||
|
path.node.declarations.forEach((el) => {
|
||||||
|
const init = el.init as CallExpression
|
||||||
|
if ((init.callee as Identifier).name === TUONO_DYNAMIC_FN_ID) {
|
||||||
|
const importName = (el.id as Identifier).name
|
||||||
|
const importPath = (
|
||||||
|
(
|
||||||
|
(init.arguments[0] as ArrowFunctionExpression)
|
||||||
|
.body as CallExpression
|
||||||
|
).arguments[0] as StringLiteral
|
||||||
|
).value
|
||||||
|
|
||||||
|
if (importName && importPath) {
|
||||||
|
const importDeclaration = t.importDeclaration(
|
||||||
|
[t.importDefaultSpecifier(t.identifier(importName))],
|
||||||
|
t.stringLiteral(importPath),
|
||||||
|
)
|
||||||
|
|
||||||
|
path.replaceWith(importDeclaration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LazyLoadingPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'vite-plugin-tuono-lazy-loading',
|
||||||
|
enforce: 'pre',
|
||||||
|
transform(code, _id, opts): string | undefined | null {
|
||||||
|
if (
|
||||||
|
code.includes(TUONO_DYNAMIC_FN_ID) &&
|
||||||
|
code.includes(TUONO_MAIN_PACKAGE)
|
||||||
|
) {
|
||||||
|
const res = babel.transformSync(code, {
|
||||||
|
plugins: [
|
||||||
|
['@babel/plugin-syntax-jsx', {}],
|
||||||
|
['@babel/plugin-syntax-typescript', { isTSX: true }],
|
||||||
|
[RemoveTuonoLazyImport],
|
||||||
|
[!opts?.ssr ? ImportReactLazy : []],
|
||||||
|
[opts?.ssr ? TurnLazyIntoStaticImport : []],
|
||||||
|
],
|
||||||
|
sourceMaps: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return res?.code
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { it, expect, describe } from 'vitest'
|
||||||
|
import { LazyLoadingPlugin } from '../src'
|
||||||
|
|
||||||
|
const SOURCE_CODE = `
|
||||||
|
import { createRoute, dynamic } from 'tuono'
|
||||||
|
|
||||||
|
const IndexImport = dynamic(() => import('./../src/routes/index'))
|
||||||
|
const PokemonspokemonImport = dynamic(
|
||||||
|
() => import('./../src/routes/pokemons/[pokemon]'),
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
const CLIENT_RESULT = `import { lazy } from "react";
|
||||||
|
import { createRoute } from 'tuono';
|
||||||
|
const IndexImport = lazy(() => import('./../src/routes/index'));
|
||||||
|
const PokemonspokemonImport = lazy(() => import('./../src/routes/pokemons/[pokemon]'));`
|
||||||
|
|
||||||
|
const SERVER_RESULT = `import { createRoute } from 'tuono';
|
||||||
|
import IndexImport from "./../src/routes/index";
|
||||||
|
import PokemonspokemonImport from "./../src/routes/pokemons/[pokemon]";`
|
||||||
|
|
||||||
|
describe('Transpile tuono source', () => {
|
||||||
|
it('Into the client bundle', () => {
|
||||||
|
const bundle = LazyLoadingPlugin().transform?.(SOURCE_CODE, 'id')
|
||||||
|
expect(bundle).toBe(CLIENT_RESULT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Into the server bundle', () => {
|
||||||
|
const bundle = LazyLoadingPlugin().transform?.(SOURCE_CODE, 'id', {
|
||||||
|
ssr: true,
|
||||||
|
})
|
||||||
|
expect(bundle).toBe(SERVER_RESULT)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||||
|
import { tanstackBuildConfig } from '@tanstack/config/build'
|
||||||
|
|
||||||
|
const config = defineConfig({})
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
config,
|
||||||
|
tanstackBuildConfig({
|
||||||
|
entry: './src/index.ts',
|
||||||
|
srcDir: './src',
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
pnpm-lock.yaml
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "tuono",
|
"name": "tuono",
|
||||||
"version": "0.1.2",
|
"version": "0.4.1",
|
||||||
"description": "The react/rust fullstack framework",
|
"description": "The react/rust fullstack framework",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite build --watch",
|
"dev": "vite build --watch",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
|
"lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc",
|
||||||
"format": "prettier -u --write '**/*'",
|
"format": "prettier -u --write --ignore-unknown '**/*'",
|
||||||
|
"format:check": "prettier --check --ignore-unknown '**/*'",
|
||||||
"types": "tsc --noEmit",
|
"types": "tsc --noEmit",
|
||||||
"test": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "dist/esm/index.d.ts",
|
"types": "dist/esm/index.d.ts",
|
||||||
@@ -87,6 +89,7 @@
|
|||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"prettier": "^3.2.4",
|
"prettier": "^3.2.4",
|
||||||
|
"tuono-lazy-fn-vite-plugin": "workspace:*",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.2.11",
|
||||||
"zustand": "4.4.7"
|
"zustand": "4.4.7"
|
||||||
},
|
},
|
||||||
@@ -94,11 +97,20 @@
|
|||||||
"@testing-library/jest-dom": "^6.4.5",
|
"@testing-library/jest-dom": "^6.4.5",
|
||||||
"@testing-library/react": "^15.0.7",
|
"@testing-library/react": "^15.0.7",
|
||||||
"@types/babel-traverse": "^6.25.10",
|
"@types/babel-traverse": "^6.25.10",
|
||||||
|
"@types/babel__traverse": "^7.20.6",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"vitest": "^1.5.2"
|
"vitest": "^1.5.2"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"keywords": [],
|
"keywords": [
|
||||||
|
"react",
|
||||||
|
"typescript",
|
||||||
|
"fullstack",
|
||||||
|
"framework",
|
||||||
|
"rust"
|
||||||
|
],
|
||||||
"author": "Valerio Ageno",
|
"author": "Valerio Ageno",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { build, createServer, InlineConfig } from 'vite'
|
import { build, createServer, InlineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import react from '@vitejs/plugin-react-swc'
|
||||||
import { ViteFsRouter } from './tuono-vite-plugin'
|
import { ViteFsRouter } from './tuono-vite-plugin'
|
||||||
|
import { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin'
|
||||||
|
|
||||||
const BASE_CONFIG: InlineConfig = {
|
const BASE_CONFIG: InlineConfig = {
|
||||||
root: '.tuono',
|
root: '.tuono',
|
||||||
@@ -8,9 +9,11 @@ const BASE_CONFIG: InlineConfig = {
|
|||||||
publicDir: '../public',
|
publicDir: '../public',
|
||||||
cacheDir: 'cache',
|
cacheDir: 'cache',
|
||||||
envDir: '../',
|
envDir: '../',
|
||||||
plugins: [react(), ViteFsRouter()],
|
plugins: [react(), ViteFsRouter(), LazyLoadingPlugin()],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VITE_PORT = 3001
|
||||||
|
|
||||||
export function developmentSSRBundle() {
|
export function developmentSSRBundle() {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
await build({
|
await build({
|
||||||
@@ -42,8 +45,10 @@ export function developmentCSRWatch() {
|
|||||||
;(async () => {
|
;(async () => {
|
||||||
const server = await createServer({
|
const server = await createServer({
|
||||||
...BASE_CONFIG,
|
...BASE_CONFIG,
|
||||||
|
// Entry point for the development vite proxy
|
||||||
|
base: '/vite-server/',
|
||||||
server: {
|
server: {
|
||||||
port: 3001,
|
port: VITE_PORT,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -62,21 +67,21 @@ export function buildProd() {
|
|||||||
;(async () => {
|
;(async () => {
|
||||||
await build({
|
await build({
|
||||||
...BASE_CONFIG,
|
...BASE_CONFIG,
|
||||||
manifest: true,
|
|
||||||
build: {
|
build: {
|
||||||
outDir: '../out/client',
|
manifest: true,
|
||||||
},
|
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
outDir: '../out/client',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: './.tuono/client-main.tsx',
|
input: './.tuono/client-main.tsx',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await build({
|
await build({
|
||||||
...BASE_CONFIG,
|
...BASE_CONFIG,
|
||||||
build: {
|
build: {
|
||||||
ssr: true,
|
ssr: true,
|
||||||
minify: false,
|
minify: true,
|
||||||
outDir: '../out/server',
|
outDir: '../out/server',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
removeGroups,
|
removeGroups,
|
||||||
removeUnderscores,
|
removeUnderscores,
|
||||||
removeLayoutSegments,
|
removeLayoutSegments,
|
||||||
removeTrailingUnderscores,
|
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
|
||||||
import type { Config, RouteNode, RouteSubNode } from './types'
|
import type { Config, RouteNode, RouteSubNode } from './types'
|
||||||
@@ -151,7 +150,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
const taskId = latestTask + 1
|
const taskId = latestTask + 1
|
||||||
latestTask = taskId
|
latestTask = taskId
|
||||||
|
|
||||||
const start = Date.now()
|
|
||||||
const { routeNodes: beforeRouteNodes, rustHandlersNodes } =
|
const { routeNodes: beforeRouteNodes, rustHandlersNodes } =
|
||||||
await getRouteNodes(config)
|
await getRouteNodes(config)
|
||||||
|
|
||||||
@@ -232,7 +230,9 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
|
|
||||||
const imports = [
|
const imports = [
|
||||||
...sortedRouteNodes.map((node) => {
|
...sortedRouteNodes.map((node) => {
|
||||||
return `import ${node.variableName}Import from './${replaceBackslash(
|
return `const ${
|
||||||
|
node.variableName
|
||||||
|
}Import = dynamic(() => import('./${replaceBackslash(
|
||||||
removeExt(
|
removeExt(
|
||||||
path.relative(
|
path.relative(
|
||||||
path.dirname(config.generatedRouteTree),
|
path.dirname(config.generatedRouteTree),
|
||||||
@@ -240,7 +240,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
)}'`
|
)}'))`
|
||||||
}),
|
}),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|
||||||
@@ -253,9 +253,6 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
const createRouteUpdates = [
|
const createRouteUpdates = [
|
||||||
sortedRouteNodes
|
sortedRouteNodes
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
const loaderNode = routePiecesByPath[node.routePath]?.loader
|
|
||||||
const lazyComponentNode = routePiecesByPath[node.routePath]?.lazy
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`const ${node.variableName}Route = ${node.variableName}.update({
|
`const ${node.variableName}Route = ${node.variableName}.update({
|
||||||
${[
|
${[
|
||||||
@@ -267,30 +264,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(',')}
|
.join(',')}
|
||||||
} as any)`,
|
})`,
|
||||||
// Verify if needed
|
|
||||||
loaderNode
|
|
||||||
? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash(
|
|
||||||
removeExt(
|
|
||||||
path.relative(
|
|
||||||
path.dirname(config.generatedRouteTree),
|
|
||||||
path.resolve(config.folderName, loaderNode.filePath),
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)}'), 'loader') })`
|
|
||||||
: '',
|
|
||||||
lazyComponentNode
|
|
||||||
? `.lazy(() => import('./${replaceBackslash(
|
|
||||||
removeExt(
|
|
||||||
path.relative(
|
|
||||||
path.dirname(config.generatedRouteTree),
|
|
||||||
path.resolve(config.folderName, lazyComponentNode.filePath),
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)}').then((d) => d.Route))`
|
|
||||||
: '',
|
|
||||||
].join('')
|
].join('')
|
||||||
})
|
})
|
||||||
.join('\n\n'),
|
.join('\n\n'),
|
||||||
@@ -298,7 +272,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
|||||||
|
|
||||||
const routeImports = [
|
const routeImports = [
|
||||||
'// This file is auto-generated by Tuono',
|
'// This file is auto-generated by Tuono',
|
||||||
"import { createRoute } from 'tuono'",
|
"import { createRoute, dynamic } from 'tuono'",
|
||||||
[
|
[
|
||||||
`import RootImport from './${replaceBackslash(
|
`import RootImport from './${replaceBackslash(
|
||||||
path.relative(
|
path.relative(
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const eliminateUnreferencedIdentifiers = (
|
|||||||
programPath.scope.crawl()
|
programPath.scope.crawl()
|
||||||
|
|
||||||
programPath.traverse({
|
programPath.traverse({
|
||||||
VariableDeclarator(path) {
|
VariableDeclarator(path: any) {
|
||||||
if (path.node.id.type === 'Identifier') {
|
if (path.node.id.type === 'Identifier') {
|
||||||
const local = path.get('id') as NodePath<BabelTypes.Identifier>
|
const local = path.get('id') as NodePath<BabelTypes.Identifier>
|
||||||
if (shouldBeRemoved(local)) {
|
if (shouldBeRemoved(local)) {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import { hydrateRoot } from 'react-dom/client'
|
||||||
import { RouterProvider, createRouter } from '../router'
|
import { RouterProvider, createRouter } from '../router'
|
||||||
|
|
||||||
export function hydrate(routeTree: any) {
|
type RouteTree = any
|
||||||
|
|
||||||
|
export function hydrate(routeTree: RouteTree): void {
|
||||||
// Create a new router instance
|
// Create a new router instance
|
||||||
const router = createRouter({ routeTree })
|
const router = createRouter({ routeTree })
|
||||||
|
|
||||||
// Render the app
|
// eslint-disable-next-line
|
||||||
const rootElement = document.getElementById('__tuono')!
|
const rootElement = document.getElementById('__tuono')!
|
||||||
|
|
||||||
ReactDOM.hydrateRoot(
|
hydrateRoot(
|
||||||
rootElement,
|
rootElement,
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import {
|
|||||||
createRouter,
|
createRouter,
|
||||||
Link,
|
Link,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
|
dynamic,
|
||||||
} from './router'
|
} from './router'
|
||||||
|
|
||||||
export type { TuonoProps } from './types'
|
export type { TuonoProps } from './types'
|
||||||
|
|
||||||
export { createRoute, createRootRoute, createRouter, Link, RouterProvider }
|
export {
|
||||||
|
createRoute,
|
||||||
|
createRootRoute,
|
||||||
|
createRouter,
|
||||||
|
Link,
|
||||||
|
RouterProvider,
|
||||||
|
dynamic,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as React from 'react'
|
||||||
import { useRouterStore } from '../hooks/useRouterStore'
|
import { useRouterStore } from '../hooks/useRouterStore'
|
||||||
import type { AnchorHTMLAttributes, MouseEvent } from 'react'
|
import type { AnchorHTMLAttributes, MouseEvent } from 'react'
|
||||||
|
|
||||||
@@ -7,7 +8,16 @@ export default function Link(
|
|||||||
const handleTransition = (e: MouseEvent<HTMLAnchorElement>): void => {
|
const handleTransition = (e: MouseEvent<HTMLAnchorElement>): void => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
props.onClick?.(e)
|
props.onClick?.(e)
|
||||||
useRouterStore.setState({ location: { pathname: props.href || '' } })
|
useRouterStore.setState({
|
||||||
|
// TODO: Refine store update
|
||||||
|
location: {
|
||||||
|
href: props.href || '',
|
||||||
|
pathname: props.href || '',
|
||||||
|
search: undefined,
|
||||||
|
searchStr: '',
|
||||||
|
hash: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
history.pushState(props.href, '', props.href)
|
history.pushState(props.href, '', props.href)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as React from 'react'
|
||||||
import { useRouter } from '../hooks/useRouter'
|
import { useRouter } from '../hooks/useRouter'
|
||||||
import { useRouterStore } from '../hooks/useRouterStore'
|
import { useRouterStore } from '../hooks/useRouterStore'
|
||||||
import type { Route } from '../route'
|
import type { Route } from '../route'
|
||||||
@@ -11,6 +12,14 @@ interface MatchesProps {
|
|||||||
|
|
||||||
const DYNAMIC_PATH_REGEX = /\[(.*?)\]/
|
const DYNAMIC_PATH_REGEX = /\[(.*?)\]/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This function is also implemented on server side to match the bundle
|
||||||
|
* file to load at the first rendering.
|
||||||
|
*
|
||||||
|
* File: crates/tuono_lib/src/payload.rs
|
||||||
|
*
|
||||||
|
* Optimizations should occour on both
|
||||||
|
*/
|
||||||
export function getRouteByPathname(pathname: string): Route | undefined {
|
export function getRouteByPathname(pathname: string): Route | undefined {
|
||||||
const { routesById } = useRouter()
|
const { routesById } = useRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as React from 'react'
|
||||||
import { useRouter } from '../hooks/useRouter'
|
import { useRouter } from '../hooks/useRouter'
|
||||||
import { RouteMatch } from './RouteMatch'
|
import { RouteMatch } from './RouteMatch'
|
||||||
import Link from './Link'
|
import Link from './Link'
|
||||||
@@ -9,7 +10,7 @@ export default function NotFound(): JSX.Element {
|
|||||||
|
|
||||||
// Check if exists a custom 404 error page
|
// Check if exists a custom 404 error page
|
||||||
if (custom404Route) {
|
if (custom404Route) {
|
||||||
return <RouteMatch route={custom404Route} />
|
return <RouteMatch route={custom404Route} serverSideProps={{}} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as React from 'react'
|
||||||
import type { Route } from '../route'
|
import type { Route } from '../route'
|
||||||
import { useServerSideProps } from '../hooks/useServerSideProps'
|
import { useServerSideProps } from '../hooks/useServerSideProps'
|
||||||
|
|
||||||
@@ -19,12 +20,19 @@ export const RouteMatch = ({
|
|||||||
const { data, isLoading } = useServerSideProps(route, serverSideProps)
|
const { data, isLoading } = useServerSideProps(route, serverSideProps)
|
||||||
|
|
||||||
if (!route.isRoot) {
|
if (!route.isRoot) {
|
||||||
return route.options.getParentRoute().component({
|
const Root = route.options.getParentRoute()
|
||||||
children: route.options.component({ data, isLoading }),
|
return (
|
||||||
data,
|
<Root.component data={data} isLoading={isLoading}>
|
||||||
isLoading,
|
<React.Suspense>
|
||||||
})
|
<route.options.component data={data} isLoading={isLoading} />
|
||||||
|
</React.Suspense>
|
||||||
|
</Root.component>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return route.options.component({ data, isLoading })
|
return (
|
||||||
|
<React.Suspense>
|
||||||
|
<route.options.component data={data} isLoading={isLoading} />
|
||||||
|
</React.Suspense>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import type { Router } from '../router'
|
import type { Router } from '../router'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const routerContext = React.createContext<Router>(null!)
|
const routerContext = React.createContext<Router>(null!)
|
||||||
|
|
||||||
const TUONO_CONTEXT = '__TUONO_CONTEXT__'
|
const TUONO_CONTEXT = '__TUONO_CONTEXT__'
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { getRouterContext } from './RouterContext'
|
import { getRouterContext } from './RouterContext'
|
||||||
import { Matches } from './Matches'
|
import { Matches } from './Matches'
|
||||||
import { useRouterStore } from '../hooks/useRouterStore'
|
import { useListenBrowserUrlUpdates } from '../hooks/useListenBrowserUrlUpdates'
|
||||||
import React, { useEffect, useLayoutEffect, type ReactNode } from 'react'
|
import React, { type ReactNode } from 'react'
|
||||||
|
import { initRouterStore } from '../hooks/useRouterStore'
|
||||||
|
import type { ServerProps } from '../types'
|
||||||
|
|
||||||
type Router = any
|
type Router = any
|
||||||
|
|
||||||
@@ -10,6 +12,11 @@ interface RouterContextProviderProps {
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RouterProviderProps {
|
||||||
|
router: Router
|
||||||
|
serverProps?: ServerProps
|
||||||
|
}
|
||||||
|
|
||||||
function RouterContextProvider({
|
function RouterContextProvider({
|
||||||
router,
|
router,
|
||||||
children,
|
children,
|
||||||
@@ -21,7 +28,6 @@ function RouterContextProvider({
|
|||||||
...rest,
|
...rest,
|
||||||
context: {
|
context: {
|
||||||
...router.options.context,
|
...router.options.context,
|
||||||
...rest.context,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -38,68 +44,13 @@ function RouterContextProvider({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouterProviderProps {
|
|
||||||
router: Router
|
|
||||||
serverProps?: ServerProps
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerProps {
|
|
||||||
router: Location
|
|
||||||
props: any
|
|
||||||
}
|
|
||||||
|
|
||||||
const initRouterStore = (props?: ServerProps): void => {
|
|
||||||
const updateLocation = useRouterStore((st) => st.updateLocation)
|
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
updateLocation({
|
|
||||||
pathname: props?.router.pathname || '',
|
|
||||||
hash: '',
|
|
||||||
href: '',
|
|
||||||
searchStr: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const { pathname, hash, href, search } = window.location
|
|
||||||
updateLocation({
|
|
||||||
pathname,
|
|
||||||
hash,
|
|
||||||
href,
|
|
||||||
searchStr: search,
|
|
||||||
search: new URLSearchParams(search),
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
const useListenUrlUpdates = (): void => {
|
|
||||||
const updateLocation = useRouterStore((st) => st.updateLocation)
|
|
||||||
|
|
||||||
const updateLocationOnPopStateChange = ({ target }: any): void => {
|
|
||||||
const { pathname, hash, href, search } = target.location
|
|
||||||
updateLocation({
|
|
||||||
pathname,
|
|
||||||
hash,
|
|
||||||
href,
|
|
||||||
searchStr: search,
|
|
||||||
search: new URLSearchParams(search),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('popstate', updateLocationOnPopStateChange)
|
|
||||||
return (): void => {
|
|
||||||
window.removeEventListener('popstate', updateLocationOnPopStateChange)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RouterProvider({
|
export function RouterProvider({
|
||||||
router,
|
router,
|
||||||
serverProps,
|
serverProps,
|
||||||
}: RouterProviderProps): JSX.Element {
|
}: RouterProviderProps): JSX.Element {
|
||||||
initRouterStore(serverProps)
|
initRouterStore(serverProps)
|
||||||
|
|
||||||
useListenUrlUpdates()
|
useListenBrowserUrlUpdates()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterContextProvider router={router}>
|
<RouterContextProvider router={router}>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to lazy load any component.
|
||||||
|
*
|
||||||
|
* The function acts exactly like React.lazy function but also renders the component on the server.
|
||||||
|
* If you want to just load the component client side use directly the react's lazy function.
|
||||||
|
*
|
||||||
|
* It can be wrapped within a React.Suspense component in order to handle its loading state.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export const dynamic = (importFn: () => JSX.Element): JSX.Element => {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* This function is just a placeholder. The real work is done by the bundler.
|
||||||
|
* The custom babel plugin will create two different bundles for the client and the server.
|
||||||
|
*
|
||||||
|
* The client will import the React's lazy function while the server will statically
|
||||||
|
* import the file.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* // User code
|
||||||
|
* import { dynamic } from 'tuono'
|
||||||
|
* const MyComponent = dynamic(() => import('./my-component'))
|
||||||
|
*
|
||||||
|
* // Client side generated code
|
||||||
|
* import { lazy } from 'react'
|
||||||
|
* const MyComponent = lazy(() => import('./my-component'))
|
||||||
|
*
|
||||||
|
* // Server side generated code
|
||||||
|
* import MyComponent from './my-component'
|
||||||
|
*
|
||||||
|
* Check the `lazy-fn-vite-plugin` package for more
|
||||||
|
*/
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useRouterStore } from './useRouterStore'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This hook is meant to handle just browser related location updates
|
||||||
|
* like the back and forward buttons.
|
||||||
|
*/
|
||||||
|
export const useListenBrowserUrlUpdates = (): void => {
|
||||||
|
const updateLocation = useRouterStore((st) => st.updateLocation)
|
||||||
|
|
||||||
|
const updateLocationOnPopStateChange = ({ target }: any): void => {
|
||||||
|
const { pathname, hash, href, search } = target.location
|
||||||
|
updateLocation({
|
||||||
|
pathname,
|
||||||
|
hash,
|
||||||
|
href,
|
||||||
|
searchStr: search,
|
||||||
|
search: new URLSearchParams(search),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('popstate', updateLocationOnPopStateChange)
|
||||||
|
return (): void => {
|
||||||
|
window.removeEventListener('popstate', updateLocationOnPopStateChange)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { useLayoutEffect } from 'react'
|
||||||
|
|
||||||
|
import type { ServerProps } from '../types'
|
||||||
|
|
||||||
export interface ParsedLocation {
|
export interface ParsedLocation {
|
||||||
href: string
|
href: string
|
||||||
@@ -20,6 +23,30 @@ interface RouterState {
|
|||||||
updateLocation: (loc: ParsedLocation) => void
|
updateLocation: (loc: ParsedLocation) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const initRouterStore = (props?: ServerProps): void => {
|
||||||
|
const updateLocation = useRouterStore((st) => st.updateLocation)
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
updateLocation({
|
||||||
|
pathname: props?.router.pathname || '',
|
||||||
|
hash: '',
|
||||||
|
href: '',
|
||||||
|
searchStr: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const { pathname, hash, href, search } = window.location
|
||||||
|
updateLocation({
|
||||||
|
pathname,
|
||||||
|
hash,
|
||||||
|
href,
|
||||||
|
searchStr: search,
|
||||||
|
search: new URLSearchParams(search),
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
export const useRouterStore = create<RouterState>()((set) => ({
|
export const useRouterStore = create<RouterState>()((set) => ({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isTransitioning: false,
|
isTransitioning: false,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import type { Route } from '../route'
|
import type { Route } from '../route'
|
||||||
import { useRouterStore } from './useRouterStore'
|
import { useRouterStore } from './useRouterStore'
|
||||||
|
import { fromUrlToParsedLocation } from '../utils/from-url-to-parsed-location'
|
||||||
|
|
||||||
const isServer = typeof document === 'undefined'
|
const isServer = typeof document === 'undefined'
|
||||||
|
|
||||||
@@ -9,19 +10,41 @@ interface UseServerSidePropsReturn {
|
|||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__TUONO_SSR_PROPS__: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TuonoApi {
|
||||||
|
data?: any
|
||||||
|
info: {
|
||||||
|
redirect_destination?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchClientSideData = async (): Promise<TuonoApi> => {
|
||||||
|
const res = await fetch(`/__tuono/data${location.pathname}`)
|
||||||
|
const data: TuonoApi = await res.json()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Use the props provided by the SSR and dehydrate the
|
* Use the props provided by the SSR and dehydrate the
|
||||||
* props for client side usage.
|
* props for client side usage.
|
||||||
*
|
*
|
||||||
* In case is a client fetch the remote data API
|
* In case is a client fetch the remote data API
|
||||||
*/
|
*/
|
||||||
export function useServerSideProps(
|
export function useServerSideProps<T>(
|
||||||
route: Route,
|
route: Route,
|
||||||
// User defined props
|
// User defined props
|
||||||
serverSideProps: any,
|
serverSideProps: T,
|
||||||
): UseServerSidePropsReturn {
|
): UseServerSidePropsReturn {
|
||||||
const isFirstRendering = useRef<boolean>(true)
|
const isFirstRendering = useRef<boolean>(true)
|
||||||
const location = useRouterStore((st) => st.location)
|
const [location, updateLocation] = useRouterStore((st) => [
|
||||||
|
st.location,
|
||||||
|
st.updateLocation,
|
||||||
|
])
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(
|
const [isLoading, setIsLoading] = useState<boolean>(
|
||||||
// Force loading if has handler
|
// Force loading if has handler
|
||||||
route.options.hasHandler &&
|
route.options.hasHandler &&
|
||||||
@@ -47,8 +70,22 @@ export function useServerSideProps(
|
|||||||
;(async (): Promise<void> => {
|
;(async (): Promise<void> => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/__tuono/data${location.pathname}`)
|
const response = await fetchClientSideData()
|
||||||
setData(await res.json())
|
if (response.info.redirect_destination) {
|
||||||
|
const parsedLocation = fromUrlToParsedLocation(
|
||||||
|
response.info.redirect_destination,
|
||||||
|
)
|
||||||
|
|
||||||
|
history.pushState(
|
||||||
|
parsedLocation.pathname,
|
||||||
|
'',
|
||||||
|
parsedLocation.pathname,
|
||||||
|
)
|
||||||
|
|
||||||
|
updateLocation(parsedLocation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setData(response.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw Error('Failed loading Server Side Data', { cause: error })
|
throw Error('Failed loading Server Side Data', { cause: error })
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__TUONO_SSR__PROPS__: any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RouterProvider } from './components/RouterProvider'
|
export { RouterProvider } from './components/RouterProvider'
|
||||||
export { default as Link } from './components/Link'
|
export { default as Link } from './components/Link'
|
||||||
export { createRouter } from './router'
|
export { createRouter } from './router'
|
||||||
export { createRoute, createRootRoute, getRouteApi } from './route'
|
export { createRoute, createRootRoute } from './route'
|
||||||
|
export { dynamic } from './dynamic'
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ export const rootRouteId = '__root__'
|
|||||||
|
|
||||||
export class Route {
|
export class Route {
|
||||||
parentRoute!: any
|
parentRoute!: any
|
||||||
id: number
|
id?: string
|
||||||
fullPath!: string
|
fullPath!: string
|
||||||
path: string
|
path?: string
|
||||||
options: any
|
options: any
|
||||||
|
|
||||||
children?: Route[]
|
children?: Route[]
|
||||||
router: RouterType
|
router: RouterType
|
||||||
isRoot: boolean
|
isRoot: boolean
|
||||||
originalIndex: number
|
originalIndex?: number
|
||||||
component: () => JSX.Element
|
component: () => JSX.Element
|
||||||
|
|
||||||
constructor(options: RouteOptions) {
|
constructor(options: RouteOptions) {
|
||||||
@@ -40,7 +40,6 @@ export class Route {
|
|||||||
|
|
||||||
const isRoot = !this.options?.path && !this.options?.id
|
const isRoot = !this.options?.path && !this.options?.id
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
this.parentRoute = this.options?.getParentRoute?.()
|
this.parentRoute = this.options?.getParentRoute?.()
|
||||||
|
|
||||||
if (isRoot) {
|
if (isRoot) {
|
||||||
@@ -75,11 +74,9 @@ export class Route {
|
|||||||
const fullPath =
|
const fullPath =
|
||||||
id === rootRouteId ? '/' : joinPaths([this.parentRoute.fullPath, path])
|
id === rootRouteId ? '/' : joinPaths([this.parentRoute.fullPath, path])
|
||||||
|
|
||||||
this.path = path as TPath
|
this.path = path
|
||||||
this.id = id as TId
|
this.id = id
|
||||||
// this.customId = customId as TCustomId
|
this.fullPath = fullPath
|
||||||
this.fullPath = fullPath as TFullPath
|
|
||||||
this.to = fullPath as TrimPathRight<TFullPath>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addChildren(routes: Route[]): Route {
|
addChildren(routes: Route[]): Route {
|
||||||
@@ -92,28 +89,8 @@ export class Route {
|
|||||||
this.isRoot = options.isRoot || !options.getParentRoute
|
this.isRoot = options.isRoot || !options.getParentRoute
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
useRouteContext = () => {}
|
|
||||||
|
|
||||||
useParams = () => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRootRoute(options?: RouteOptions): Route {
|
export function createRootRoute(options: RouteOptions): Route {
|
||||||
return new Route({ ...options, isRoot: true })
|
return new Route({ ...options, isRoot: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRouteApi(id: string): RouteApi {
|
|
||||||
return new RouteApi(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
class RouteApi {
|
|
||||||
id: string
|
|
||||||
|
|
||||||
constructor(id: string) {
|
|
||||||
this.id = id
|
|
||||||
}
|
|
||||||
|
|
||||||
useParams = () => {}
|
|
||||||
|
|
||||||
useRouteContext = () => {}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type RouteTree = any
|
|||||||
interface CreateRouter {
|
interface CreateRouter {
|
||||||
routeTree: RouteTree
|
routeTree: RouteTree
|
||||||
basePath?: string
|
basePath?: string
|
||||||
options: RouteOptions
|
options?: RouteOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteOptions {
|
interface RouteOptions {
|
||||||
@@ -14,9 +14,10 @@ interface RouteOptions {
|
|||||||
hasHandler?: boolean
|
hasHandler?: boolean
|
||||||
routeTree?: RouteTree
|
routeTree?: RouteTree
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RouterType = any
|
export type RouterType = any
|
||||||
|
|
||||||
export function createRouter(options: CreateRouterArgs): Router {
|
export function createRouter(options: CreateRouter): Router {
|
||||||
return new Router(options)
|
return new Router(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,32 +50,18 @@ export class Router {
|
|||||||
|
|
||||||
this.#updateBasePath(newOptions.basePath)
|
this.#updateBasePath(newOptions.basePath)
|
||||||
|
|
||||||
// NOTE: next iteration
|
|
||||||
this.#historyUpdate()
|
|
||||||
|
|
||||||
// NOTE: next iteration
|
|
||||||
this.#storeUpdate()
|
|
||||||
|
|
||||||
if (this.options.routeTree !== this.routeTree) {
|
if (this.options.routeTree !== this.routeTree) {
|
||||||
this.routeTree = this.options.routeTree
|
this.routeTree = this.options.routeTree
|
||||||
this.#buildRouteTree()
|
this.#buildRouteTree()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#historyUpdate = (): void => {
|
|
||||||
// TODO: update history
|
|
||||||
}
|
|
||||||
|
|
||||||
#storeUpdate = (): void => {
|
|
||||||
// TODO: update store
|
|
||||||
}
|
|
||||||
|
|
||||||
#buildRouteTree = (): void => {
|
#buildRouteTree = (): void => {
|
||||||
const recurseRoutes = (childRoutes: Route[]): void => {
|
const recurseRoutes = (childRoutes: Route[]): void => {
|
||||||
childRoutes.forEach((route: Route, i: number) => {
|
childRoutes.forEach((route: Route, i: number) => {
|
||||||
route.init(i)
|
route.init(i)
|
||||||
|
|
||||||
this.routesById[route.id] = route
|
this.routesById[route.id || ''] = route
|
||||||
|
|
||||||
if (!route.isRoot && route.options.path) {
|
if (!route.isRoot && route.options.path) {
|
||||||
const trimmedFullPath = trimPathRight(route.fullPath)
|
const trimmedFullPath = trimPathRight(route.fullPath)
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ export interface Segment {
|
|||||||
type: 'pathname' | 'param' | 'wildcard'
|
type: 'pathname' | 'param' | 'wildcard'
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerProps {
|
||||||
|
router: Location
|
||||||
|
props: any
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ParsedLocation } from '../hooks/useRouterStore'
|
||||||
|
|
||||||
|
// TODO: improve the whole react/rust URL parsing logic
|
||||||
|
export function fromUrlToParsedLocation(href: string): ParsedLocation {
|
||||||
|
/*
|
||||||
|
* This function works on both server and client.
|
||||||
|
* For this reason we can't rely on the browser's URL api
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
href,
|
||||||
|
pathname: href,
|
||||||
|
search: undefined,
|
||||||
|
searchStr: '',
|
||||||
|
hash: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,25 +4,48 @@ import { renderToString, renderToStaticMarkup } from 'react-dom/server'
|
|||||||
import { RouterProvider } from '../router'
|
import { RouterProvider } from '../router'
|
||||||
import { createRouter } from '../router'
|
import { createRouter } from '../router'
|
||||||
|
|
||||||
export function serverSideRendering(routeTree: any) {
|
type RouteTree = any
|
||||||
return function render(payload: string | undefined): string {
|
type Mode = 'Dev' | 'Prod'
|
||||||
const props = payload ? JSON.parse(payload) : {}
|
|
||||||
|
|
||||||
const router = createRouter({ routeTree }) // Render the app
|
const TUONO_DEV_SERVER_PORT = 3000
|
||||||
|
const VITE_PROXY_PATH = '/vite-server'
|
||||||
|
|
||||||
const app = renderToString(
|
const VITE_DEV_AND_HMR = `<script type="module">
|
||||||
<RouterProvider router={router} serverProps={props} />,
|
import RefreshRuntime from 'http://localhost:${TUONO_DEV_SERVER_PORT}${VITE_PROXY_PATH}/@react-refresh'
|
||||||
)
|
|
||||||
|
|
||||||
const developmentScript = `<script type="module">
|
|
||||||
import RefreshRuntime from 'http://localhost:3001/@react-refresh'
|
|
||||||
RefreshRuntime.injectIntoGlobalHook(window)
|
RefreshRuntime.injectIntoGlobalHook(window)
|
||||||
window.$RefreshReg$ = () => {}
|
window.$RefreshReg$ = () => {}
|
||||||
window.$RefreshSig$ = () => (type) => type
|
window.$RefreshSig$ = () => (type) => type
|
||||||
window.__vite_plugin_react_preamble_installed__ = true
|
window.__vite_plugin_react_preamble_installed__ = true
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="http://localhost:3001/@vite/client"></script>
|
<script type="module" src="http://localhost:${TUONO_DEV_SERVER_PORT}${VITE_PROXY_PATH}/@vite/client"></script>
|
||||||
<script type="module" src="http://localhost:3001/client-main.tsx"></script>`
|
<script type="module" src="http://localhost:${TUONO_DEV_SERVER_PORT}${VITE_PROXY_PATH}/client-main.tsx"></script>`
|
||||||
|
|
||||||
|
function generateCssLinks(cssBundles: string[], mode: Mode): string {
|
||||||
|
if (mode === 'Dev') return ''
|
||||||
|
return cssBundles.reduce((acc, value) => {
|
||||||
|
return acc + `<link rel="stylesheet" type="text/css" href="/${value}" />\n`
|
||||||
|
}, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateJsScripts(jsBundles: string[], mode: Mode): string {
|
||||||
|
if (mode === 'Dev') return ''
|
||||||
|
return jsBundles.reduce((acc, value) => {
|
||||||
|
return acc + `<script type="module" src="/${value}"></script>\n`
|
||||||
|
}, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverSideRendering(routeTree: RouteTree) {
|
||||||
|
return function render(payload: string | undefined): string {
|
||||||
|
const props = payload ? JSON.parse(payload) : {}
|
||||||
|
|
||||||
|
const mode = props.mode as Mode
|
||||||
|
const jsBundles = props.jsBundles as string[]
|
||||||
|
const cssBundles = props.cssBundles as string[]
|
||||||
|
const router = createRouter({ routeTree }) // Render the app
|
||||||
|
|
||||||
|
const app = renderToString(
|
||||||
|
<RouterProvider router={router} serverProps={props} />,
|
||||||
|
)
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
@@ -30,6 +53,7 @@ export function serverSideRendering(routeTree: any) {
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Playground</title>
|
<title>Playground</title>
|
||||||
|
${generateCssLinks(cssBundles, mode)}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="__tuono">${app}</div>
|
<div id="__tuono">${app}</div>
|
||||||
@@ -40,7 +64,8 @@ export function serverSideRendering(routeTree: any) {
|
|||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
)}
|
)}
|
||||||
${developmentScript}
|
${generateJsScripts(jsBundles, mode)}
|
||||||
|
${mode === 'Dev' ? VITE_DEV_AND_HMR : ''}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react"
|
"jsx": "react"
|
||||||
},
|
},
|
||||||
"include": ["src", "tests", "vite.config.ts"]
|
"include": ["src", "tests", "vite.config.ts"],
|
||||||
|
"exclude": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"lint": {},
|
"lint": {},
|
||||||
"format": {},
|
"format": {},
|
||||||
|
"format:check": {},
|
||||||
|
"types": {
|
||||||
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
]
|
||||||
|
},
|
||||||
"test": {},
|
"test": {},
|
||||||
|
"test:watch": {},
|
||||||
"build": {
|
"build": {
|
||||||
"outputs": [
|
"outputs": [
|
||||||
"dist/**"
|
"dist/**"
|
||||||
|
|||||||
Reference in New Issue
Block a user