之前我們在第11篇把前端換成SvelteKit後,其實有衍生一點小問題,就是SvelteKit預設是啟用SSR,而我們放進tauri裡面要使用SPA模式,所以需要調整一下設定檔,依svelte官網的說明,先安裝adapter-static:
~/demo-app/app$ pnpm i -D @sveltejs/adapter-static
再調整修改一下設定值:
@@ app/svelte.config.js @@
import adapter from '@sveltejs/adapter-static';
- import adapter from '@sveltejs/adapter-auto';
...
- adapter: adapter(),
+ adapter: adapter({
+ fallback: 'index.html',
+ pages: 'dist',
+ assets: 'dist'
+ }),
之後需要發佈時跑的build指令,就會把產出的artifact存放在dist
資料夾裡。在tauri的設定檔app/src-tauri/tauri.conf.json
有指定輸出的資料夾:
{
"build": {
"beforeDevCommand": "pnpm i && pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
}
}
開發時tauri是去開devPath,就是pnpm跑起來的devServer,而佈署時抓的就是distDir裡面的建置結果檔。
我們試著開一下登入頁面,理論上登入頁面應該要淨空,不應該還出現一堆選單可以點擊,但我們現在的Header選單是放在Svelte的Layout中,而這個Layout是依資料夾巢狀嵌套的,我們看一下範例比較好理解:
看了這個圖應該就一目了然了,左上資料夾一層一層包起來,最後render渲染的結果就像下面那樣,裡層的無法跳脫外層,只能被放入外層設定的slot裡,所以官方建議如果要開不同的layout,就使用不同的資料夾作為分群,同一群的可以沿用(繼承)相同的版面配置。
所以我們另外開一個資料夾game,並把之前router裡的東西往裡面移動:
app/src/routes/
├── game
│ ├── about
│ │ ├── +page.svelte
│ │ └── +page.ts
│ ├── Counter.svelte
│ ├── Header.svelte
│ ├── +layout.svelte
│ ├── +page.svelte
│ ├── +page.ts
│ ├── sverdle
│ │ ├── game.test.ts
│ │ ├── game.ts
│ │ ├── how-to-play
│ │ │ ├── +page.svelte
│ │ │ └── +page.ts
│ │ ├── +page.server.ts
│ │ ├── +page.svelte
│ │ ├── reduced-motion.ts
│ │ └── words.server.ts
│ └── tic_tac_toe
│ └── +page.svelte
├── login
│ └── +page.svelte
└── styles.css
只有styles.css
留在外面,然後調整一下參照,有的IDE會幫忙調,有的不正確就要再自己修正。
@@ app/src/routes/game/tic_tac_toe/+page.svelte @@
+import { api } from '../../../api';
-import { api } from '../../api';
+import type { ErrorResponse } from '../../../model/tic_tac_toe';
-import type { ErrorResponse } from '../../model/tic_tac_toe';
+import { emptyGame } from '../../../model/tic_tac_toe';
-import { emptyGame } from '../../model/tic_tac_toe';
+import { wsClient } from "../../../api/ws_client";
-import { wsClient } from "../../api/ws_client";
@@ app/src/routes/game/+layout.svelte @@
+import '../styles.css';
-import './styles.css';
@@ app/src/routes/game/Header.svelte @@
+ <a href="/game">Home</a>
- <a href="/">Home</a>
+ <a href="/game/about">About</a>
- <a href="/about">About</a>
+ <a href="/game/sverdle">Sverdle</a>
- <a href="/sverdle">Sverdle</a>
+ <a href="/game/tic_tac_toe">井字遊戲</a>
- <a href="/tic_tac_toe">井字遊戲</a>
加上我們登入頁面:
<!-- app/src/routes/login/+page.svelte -->
<script lang="ts">
import '../styles.css';
let username = '';
let password = '';
</script>
<h1>登入頁面</h1>
<div class="text-center">
<div class="text-2xl py-2">
帳號
<input
class="w-40 border-2 border-blue-500 rounded-md h-10 text-center text-2xl"
bind:value={username}
/>
</div>
<div class="text-2xl py-2">
密碼
<input
class="w-40 border-2 border-blue-500 rounded-md h-10 text-center text-2xl"
type="password"
bind:value={password}
/>
</div>
<div class="py-2" >
<button
class="w-40 bg-amber-400 border-2 border-blue-500 rounded-md h-10 text-center text-2xl p"
>
登入
</button>
</div>
</div>
我們沒有選單加按鈕導到登入頁面,因為一般都是判斷當前使用者狀態,需要登入的話就自動重新導向到登入頁面。我們先簡單用Browser測一下,直接在網址後面加上login就可以開啟該頁面:
看起來路由和頁面渲染都沒問題,不過這時候我們的tauri app卻怪怪的:
因為 tauri app 預設會開啟前端devServer/distPath的首頁,路由為 /
。 而我們搬走相關的檔案後,首頁就變成空的沒東西了,那怎麼辦呢,雖然加個首頁就可以解決,不過這裡想帶大家用tauri app來控制視窗,還有印象先前有寫到一個|app|
,那時候說這個app
就是指tauri的應用程式嗎,我們就到那邊去動手腳,看一下有什麼用法:
// app/src-tauri/src/main.rs
use tauri::Manager;
// ... 略
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ... 略
tauri::Builder::default()
.setup(|app| {
let app_handle = app.handle();
// ... 略
let main_window = app.get_window("main").unwrap();
main_window
.eval("window.location.href = '/game'")
.unwrap();
Ok(())
})
應該很淺顯易懂吧,app
透過get_window
方法取得main
視窗,這個main
是唯一值,取得主視窗。看到這個方法的名稱也可以猜到可以有很多的視窗,沒錯,tauri可以開啟很多不同的視窗,每個視窗都有自己唯一的label
,後面有機會再作介紹。拿到主視窗物件之後,eval
是呼叫 JavaScript 的 eval(),這是什麼呢(不知道是合理的),就是把字串當作JavaScript的程式碼來跑,裡面"window.location.href = '/game'"
就是JavaScript的程式,location
是Browser作用中的網址物件,我們透過這個方法調用前端網址導向/game
。
JavaScript的eval不是很好的pattern,沒事不要亂用,君不見官方都宣稱永遠不要使用 eval!。
補上後我們的tauri app就正常了:
剛剛把畫面雛型刻好,現在在實作登入API,試想一下我們登入後取得的token,必需要在往後的Http Request中,加在Header裡送出。一般前端都會有自己的狀態管理器,像Vue有Pinia,我們先前在tauri裡也有自己做一個Context
放manager裡託管。除了程式語言的狀態管理,這邊打算使用Browser的機制,放在localStorage裡,不同於sessionStorage關閉視窗就會自動清掉,localStorage就算重開機也還是會在,大家可以依自己所需要的情境來使用。
// app/src/api/ky.ts
/** 把localStorage裡的jwt換成傳入的token */
export const setJwt = (token: string) => {
localStorage.setItem('jwt', token);
}
/** 清除既存的的jwt */
export const cleanJwt = () => {
localStorage.removeItem('jwt');
}
/** 判斷Jwt是否過期 */
const isJwtExpired = (jwt : string) => {
const jwtExpiry = JSON
.parse(atob(jwt.split('.')[1]))
.exp * 1000;
const now = new Date().getTime();
return now > jwtExpiry;
}
/** 從localStorage取得jwt,如果過期就清掉 */
export const getJwt = () => {
let jwt = localStorage.getItem('jwt');
if (jwt && !isJwtExpired(jwt)) {
return jwt;
}
localStorage.removeItem('jwt');
return null;
}
要注意一下localStorage可用的容量很小,不要亂存 XDD。
好了之後我們要在api的request裡補上Authorization的Header,這個Header要看jwt有值才帶入,沒值就整項都不要放,不能放Authorization: (空白)
,因為後端會判斷如果有Authorization
就驗章,沒有帶Authorization
就不驗章,所以放空白就會一直讓後端驗失敗,看一下我們用的http client ky.js文件,提到可以用hook的方式帶入,就試著做一版:
// app/src/api/ky.ts
export const httpClient = (): KyInstance =>
ky.create({
prefixUrl: import.meta.env.VITE_API_BASE_URL,
throwHttpErrors: false,
hooks: { // 加入 hook,其實還有很多其他類的hook
beforeRequest: [ // 在準備Request之前
request => {
const jwt = getJwt(); // 取得 jwt
if (jwt) { // 如果 jwt 不為空,才處理加Header
let token = `Bearer ${jwt}`;
request.headers.set('Authorization', token);
}
}
]
}
});
這邊只是把httpClient準備好,實作登入的API:
// app/src/api/auth.ts
import { cleanJwt, httpClient, setJwt } from './ky';
export const login = async (username: string, password: string): Promise<void> => {
cleanJwt(); // 先清掉舊有的jwt(如果有的話)
const client = httpClient();
const response = await client
.post('login', { json: { username, password } });
const status = response.status; // 登入成功為 200
if (status === 200) {
const data: { access_token: string } = await response.json();
const jwt = data.access_token;
setJwt(jwt); // 存入回傳的token
}
}
我們登入前先清掉舊的jwt,登入成功取得token才會存下來,之後如果要補強相關的功能也可以在這裡透過login回傳current user的資訊。
到我們統一的api介面註冊剛剛的login function:
// app/src/api/index.ts
import { login } from './auth'; // 使用剛剛寫好的 login
export interface Api {
// ... 略
login: (username: string, password: string)
=> Promise<void>; // 加共用的interface定義
}
const httpApi: Api = {
// ... 略
login, // rest api使用這個實作
};
const tauriApi: Api = {
// ... 略
login, // tauriAPI待實作 TODO: 待tauriAPI實作
};
回到我們登入頁:
<script lang="ts">
// ... 略
import { api } from '../../api';
const login = () =>{
api.login(username, password);
}
</script>
<button
class="..."
on:click={login}
>
先前框框都刻好了,直接把api function帶進來使用就好,實測一下,不如想像中的順利:
為什麼登入會失敗,而且還顯示CORS,原來別人也有遇到一樣的問題,我們的header加上Authorization
就會被CORS擋掉,這時候到我們的後端補上CORS中的Header設定:
// web/src/routers.rs
fn cors_config() -> Builder {
warp::cors()
.allow_any_origin()
.allow_methods(vec!["GET", "PUT", "POST", "DELETE"])
.allow_headers(vec!["Content-Type", "Authorization"]) // 加這行
}
突然覺得最後一行回傳值不用打分號真好,像什麼 return xxx ;
之類的,如果要修改還要考慮;
,rust 後面不接分號結尾,所以我們隨時可以往後串接新的東西超方便。調整好後再呼叫一次,這次API有正確回應200訊息了:
再從Application分頁進去,左側拉Local Storage看一下:
正確出現了jwt的key值和value,表示我們剛剛寫的程式有正確的作動。大家也可以試著去測測看不同登入結果,比如登入失敗後,jwt是否有如預期般被移除。
再來是添加路由守衛,當發現沒有權限進入該頁面時,若狀態為未登入則強制轉到登入頁面,若登入權限不足則強制轉向首頁或指定頁面。SvelteKit好像有關路由守衛的資料大部分都是SSR的,純client端版本有點少,找了幾項有的實作太舊版已失效,有的實作有點繞。不知道是不是因為預編譯的關係,所以不像Vue有一個Router的物件來負責路由的管控,所以可以下各種hook。既然沒有一個比較好的解法,這邊實作先用比較簡單粗暴的方式放到onMount裡判斷,
// app/src/api/auth.ts
import { goto } from '$app/navigation';
import { cleanJwt, getJwt, httpClient, setJwt } from './ky';
interface User {
name: string;
exp: number;
permissions: number[];
}
// 先把 jwt裡的payload充當 current user 來實測
export const currentUser = (): User|null => {
const jwt = getJwt();
console.log('jwt', jwt);
if (jwt) {
const payload = JSON.parse(atob(jwt.split('.')[1]));
return { ...payload, name: payload.sub };
}
return null;
};
上面...
是JavaScript的解構功能,還不會的快去學起來。可以節省很多時間。然後下面寫路由判斷式:
// app/src/api/auth.ts
export const route_guard = (): void => {
if (typeof window === 'undefined') return;
let location = window.location;
let path = location.pathname; // 取得當前路由
let user = currentUser(); // 取得當前User
// 進行權限判斷
if (path === '/game/tic_tac_toe') { // 針對特定路由判斷
// 如果沒有權限,就導回首頁
if (user?.permissions?.includes(1) ||
user?.permissions?.includes(2)) {
return; // 合格才給進入
}
goto('/game') // 不符者進行重導向
.then(() => console.log('no permission, redirect to home'));
}
};
使用就在該頁面的onMount上加呼叫這個function
<!-- app/src/routes/game/tic_tac_toe/+page.svelte -->
<script lang="ts">
import { route_guard } from "../../../api/auth";
// ... 略
onMount(async () => {
route_guard();
// ... 略
</script>
實測一下,是有正確的擋住了,有權限才給進入,不過有一個小問題是會很短暫的一瞬間顯示出該頁面的內容再立馬跳轉。其實後端的權限才是比較實際的,前端的權限主要體現在使用者體驗,如果要優化可以在選單中把使用者無權限的連結都隱藏起來,如此也不會有機會被點到,但就是有的User很愛自己亂key,所以還是要做一下把關。
最後我們讓登入成功後,可以重導向至首頁:
// app/src/api/auth.ts
export const login /* …*/
// ... 略
goto('/game').then(() => console.log('redirect to /game'));
}
這時候我們就可以在tauri app調整成為一開啟畫面就直接是登入頁面:
+main_window.eval("window.location.href = '/login'").unwrap();
-main_window.eval("window.location.href = '/game'").unwrap();
測試一下,使用不同權限的帳號登入都沒有問題。
前端我們有使用tauri app版,也有純網頁版,而tauri版先前改接gRPC服務了,而gRPC我們當時是寫在另一個執行檔,變成每次跑都要另外再開一個終端畫面跑,我們前一篇已經把http和https都併在一起了,這邊試著把gRPC也併進來。先開一個gRPC的模組:
// web/src/lib.rs
pub mod grpc;
// web/src/grpc/mod.rs
pub mod hello_world;
pub mod tic_tac_toe;
然後移動檔案:
web/src/bin/grpc/hello_world.rs
-> web/src/grpc/hello_world.rs
web/src/bin/grpc/tic_tac_toe.rs
-> web/src/grpc/tic_tac_toe.rs
再調整一下相依性:
@@ web/src/grpc/tic_tac_toe.rs @@
+use crate::error::AppError;
-use web::error::AppError;
@@ web/src/bin/grpc/main.rs @@
+use web::grpc::hello_world::{
-use hello_world::{
...
+use web::grpc::tic_tac_toe::{tic_tac_toe_server::TicTacToeServer, TicTacToeGrpcService};
-use tic_tac_toe::{tic_tac_toe_server::TicTacToeServer, TicTacToeGrpcService};
-mod hello_world;
-mod tic_tac_toe;
接著把原本的 service 抽出來:
use tonic::{transport::Server};
use tonic::transport::server::Router;
use hello_world::{greeter_server::GreeterServer, MyGreeter};
use tic_tac_toe::{tic_tac_toe_server::TicTacToeServer, TicTacToeGrpcService};
pub fn grpc_route() -> Router {
let greeter = MyGreeter::default();
let tic_tac_toe = TicTacToeGrpcService::default();
Server::builder()
.add_service(GreeterServer::new(greeter))
.add_service(TicTacToeServer::new(tic_tac_toe))
// .serve(addr) 以下刪掉,在main 裡再起服務
// .await?;
// 回傳的是一個Router物件
}
最後在main裡加上:
// web/src/main.rs
use web::grpc::grpc_route;
// ...略
async fn main() {
let _logger = Logger::builder().use_env()
.add_package("grpc") // 加這個
.add_package("tonic") // 加這個
.build();
// ...略
let grpc_addr = "[::1]:3032".parse().unwrap(); // 設定gRPC要監聽的位址
tokio::join!(
// ...略
grpc_route().serve(grpc_addr), // gRPC服務,是個Future
);
}
這樣就完成了,當然還有些可以細化的部分,比如把port號抽成env設定值等,不過目前我們可以執行原本的web執行檔,就把所有服務全部一次跑再來,再也不用開多個終端視窗跑了(分很多個終端其實也是分身的一種(?),讓tokio來幫我們跑分身還是比較輕鬆的)。
本系列專案源始碼放置於 https://github.com/kenstt/demo-app