diff --git a/crates/tuono/Cargo.toml b/crates/tuono/Cargo.toml index aeb29f66..8961002b 100644 --- a/crates/tuono/Cargo.toml +++ b/crates/tuono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono" -version = "0.2.5" +version = "0.3.0" edition = "2021" authors = ["V. Ageno <valerioageno@yahoo.it>"] description = "The react/rust fullstack framework" diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index 0c5152fc..a52fb97b 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -153,7 +153,7 @@ impl Route { } Route { - module_import: module.as_str().to_string().replace('/', "_"), + module_import: module.as_str().to_string().replace('/', "_").to_lowercase(), axum_route, } } @@ -329,6 +329,7 @@ mod tests { "/home/user/Documents/tuono/src/routes/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/UPPERCASE.rs", ]; routes @@ -340,6 +341,7 @@ mod tests { ("/about.rs", "about"), ("/posts/index.rs", "posts_index"), ("/posts/[post].rs", "posts_dyn_post"), + ("/posts/UPPERCASE.rs", "posts_uppercase"), ]; results.into_iter().for_each(|(path, module_import)| { diff --git a/crates/tuono_lib/Cargo.toml b/crates/tuono_lib/Cargo.toml index 03271b4e..67a59fb3 100644 --- a/crates/tuono_lib/Cargo.toml +++ b/crates/tuono_lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib" -version = "0.2.5" +version = "0.3.0" edition = "2021" authors = ["V. Ageno <valerioageno@yahoo.it>"] description = "The react/rust fullstack framework" @@ -24,8 +24,9 @@ serde = { version = "1.0.202", features = ["derive"] } erased-serde = "0.4.5" serde_json = "1.0" -tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.2.5"} +tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.3.0"} once_cell = "1.19.0" lazy_static = "1.5.0" regex = "1.10.5" +either = "1.13.0" diff --git a/crates/tuono_lib/src/response.rs b/crates/tuono_lib/src/response.rs index f217b55f..8b599b58 100644 --- a/crates/tuono_lib/src/response.rs +++ b/crates/tuono_lib/src/response.rs @@ -1,7 +1,7 @@ use crate::Request; use crate::{ssr::Js, Payload}; use axum::http::StatusCode; -use axum::response::{Html, IntoResponse}; +use axum::response::{Html, IntoResponse, Redirect, Response as AxumResponse}; use axum::Json; use erased_serde::Serialize; @@ -15,6 +15,41 @@ pub enum Response { 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 { @@ -32,25 +67,32 @@ impl Props { } impl Response { - pub fn render_to_string(&self, req: Request) -> impl IntoResponse { + 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::SSR.with(|ssr| ssr.borrow_mut().render_to_string(Some(&payload))) { - Ok(html) => (*http_code, Html(html)), - Err(_) => (*http_code, Html("500 Internal server error".to_string())), + Ok(html) => (*http_code, Html(html)).into_response(), + Err(_) => { + (*http_code, Html("500 Internal server error".to_string())).into_response() + } } } - // TODO: Handle here other enum arms - _ => todo!(), + 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(data)).into_response(), - _ => (StatusCode::INTERNAL_SERVER_ERROR, axum::Json("{}")).into_response(), + 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(), } } } diff --git a/crates/tuono_lib_macros/Cargo.toml b/crates/tuono_lib_macros/Cargo.toml index e5118b50..81675db3 100644 --- a/crates/tuono_lib_macros/Cargo.toml +++ b/crates/tuono_lib_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib_macros" -version = "0.2.5" +version = "0.3.0" edition = "2021" description = "The react/rust fullstack framework" repository = "https://github.com/Valerioageno/tuono" diff --git a/docs/tutorial.md b/docs/tutorial.md index 1cb28d46..77ab7ea7 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -23,6 +23,7 @@ Typescript and Rust knowledge is not a requirement though! * [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) @@ -98,7 +99,7 @@ The file `index.rs` represents the server side capabilities for the index route. - Passing server side props - Changing http status code -- Redirect/Rewrite to a different route (Available soon) +- Redirecting to a different route ## Tutorial introduction @@ -542,6 +543,41 @@ async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response 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 diff --git a/examples/tutorial/src/routes/index.tsx b/examples/tutorial/src/routes/index.tsx index 25105096..806d0732 100644 --- a/examples/tutorial/src/routes/index.tsx +++ b/examples/tutorial/src/routes/index.tsx @@ -37,6 +37,7 @@ export default function IndexPage({ </div> </div> <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} /> })} diff --git a/examples/tutorial/src/routes/pokemons/GOAT.rs b/examples/tutorial/src/routes/pokemons/GOAT.rs new file mode 100644 index 00000000..f8bcd5df --- /dev/null +++ b/examples/tutorial/src/routes/pokemons/GOAT.rs @@ -0,0 +1,7 @@ +// src/routes/pokemons/GOAT.rs +use tuono_lib::{Request, Response}; + +#[tuono_lib::handler] +async fn redirect_to_goat(_: Request<'_>, _: reqwest::Client) -> Response { + Response::Redirect("/pokemons/mewtwo".to_string()) +} diff --git a/packages/lazy-fn-vite-plugin/package.json b/packages/lazy-fn-vite-plugin/package.json index 7a600871..6da5d874 100644 --- a/packages/lazy-fn-vite-plugin/package.json +++ b/packages/lazy-fn-vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "tuono-lazy-fn-vite-plugin", - "version": "0.2.5", + "version": "0.3.0", "description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework", "scripts": { "dev": "vite build --watch", diff --git a/packages/tuono/package.json b/packages/tuono/package.json index 727cc50b..fe73c7a3 100644 --- a/packages/tuono/package.json +++ b/packages/tuono/package.json @@ -1,6 +1,6 @@ { "name": "tuono", - "version": "0.2.5", + "version": "0.3.0", "description": "The react/rust fullstack framework", "scripts": { "dev": "vite build --watch", diff --git a/packages/tuono/src/router/components/RouterProvider.tsx b/packages/tuono/src/router/components/RouterProvider.tsx index 10ca25e0..596f80de 100644 --- a/packages/tuono/src/router/components/RouterProvider.tsx +++ b/packages/tuono/src/router/components/RouterProvider.tsx @@ -1,7 +1,9 @@ import { getRouterContext } from './RouterContext' import { Matches } from './Matches' -import { useRouterStore } from '../hooks/useRouterStore' -import React, { useEffect, useLayoutEffect, type ReactNode } from 'react' +import { useListenBrowserUrlUpdates } from '../hooks/useListenBrowserUrlUpdates' +import React, { type ReactNode } from 'react' +import { initRouterStore } from '../hooks/useRouterStore' +import type { ServerProps } from '../types' type Router = any @@ -15,11 +17,6 @@ interface RouterProviderProps { serverProps?: ServerProps } -interface ServerProps { - router: Location - props: any -} - function RouterContextProvider({ router, children, @@ -47,58 +44,13 @@ function RouterContextProvider({ ) } -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({ router, serverProps, }: RouterProviderProps): JSX.Element { initRouterStore(serverProps) - useListenUrlUpdates() + useListenBrowserUrlUpdates() return ( <RouterContextProvider router={router}> diff --git a/packages/tuono/src/router/hooks/useListenBrowserUrlUpdates.tsx b/packages/tuono/src/router/hooks/useListenBrowserUrlUpdates.tsx new file mode 100644 index 00000000..7f5fafab --- /dev/null +++ b/packages/tuono/src/router/hooks/useListenBrowserUrlUpdates.tsx @@ -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) + } + }, []) +} diff --git a/packages/tuono/src/router/hooks/useRouterStore.tsx b/packages/tuono/src/router/hooks/useRouterStore.tsx index c9fd4019..faf4a5cc 100644 --- a/packages/tuono/src/router/hooks/useRouterStore.tsx +++ b/packages/tuono/src/router/hooks/useRouterStore.tsx @@ -1,4 +1,7 @@ import { create } from 'zustand' +import { useLayoutEffect } from 'react' + +import type { ServerProps } from '../types' export interface ParsedLocation { href: string @@ -20,6 +23,30 @@ interface RouterState { 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) => ({ isLoading: false, isTransitioning: false, diff --git a/packages/tuono/src/router/hooks/useServerSideProps.tsx b/packages/tuono/src/router/hooks/useServerSideProps.tsx index 9ec8628a..69c8ac29 100644 --- a/packages/tuono/src/router/hooks/useServerSideProps.tsx +++ b/packages/tuono/src/router/hooks/useServerSideProps.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react' import type { Route } from '../route' import { useRouterStore } from './useRouterStore' +import { fromUrlToParsedLocation } from '../utils/from-url-to-parsed-location' const isServer = typeof document === 'undefined' @@ -15,6 +16,19 @@ declare global { } } +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 * props for client side usage. @@ -27,7 +41,10 @@ export function useServerSideProps<T>( serverSideProps: T, ): UseServerSidePropsReturn { 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>( // Force loading if has handler route.options.hasHandler && @@ -53,8 +70,22 @@ export function useServerSideProps<T>( ;(async (): Promise<void> => { setIsLoading(true) try { - const res = await fetch(`/__tuono/data${location.pathname}`) - setData(await res.json()) + const response = await fetchClientSideData() + 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) { throw Error('Failed loading Server Side Data', { cause: error }) } finally { diff --git a/packages/tuono/src/router/types.ts b/packages/tuono/src/router/types.ts index 3ba2ea71..2daff8cc 100644 --- a/packages/tuono/src/router/types.ts +++ b/packages/tuono/src/router/types.ts @@ -2,3 +2,8 @@ export interface Segment { type: 'pathname' | 'param' | 'wildcard' value: string } + +export interface ServerProps { + router: Location + props: any +} diff --git a/packages/tuono/src/router/utils/from-url-to-parsed-location.ts b/packages/tuono/src/router/utils/from-url-to-parsed-location.ts new file mode 100644 index 00000000..b269acc3 --- /dev/null +++ b/packages/tuono/src/router/utils/from-url-to-parsed-location.ts @@ -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: '', + } +}