2023-11-12

RemixJS 使用 Jotai 取代 Context

RemixJS 使用 Jotai 取代 Context

把之前的專案,用 Jotai 取代 Context 做登入狀態的管理。原本的登入狀態是使用 cookie-session 處理的。

基本上,改用 Jotai 程式碼反而變多了,思路比較徧向以前端的方式解決問題。

以下是遇到的狀況和問題:

  1. 要將 loader 取得的資料放到 store,應該要使用 useHydrateAtoms hook。

  2. 使用 store.get()store.set() 時,似乎不會觸發 re-render;而必須使用 useAtom,應該是訂閱功能做在 useAtom 身上吧。

  3. 使用 Context 時,有些功能用後端功能處理就可以了;但 Jotai 必須使用前端的功能解決。例如登出時,使用 Context 只要在 logout 頁面的 loader() 處理即可。使用 Jotai 必須登出後,使用 useAtom 去變更 store 狀態。使用 google 登入時,有相同的問題。

  4. 經常遇到 Warning: Text content did not match 的問題。解決的方式是在使用 useAtom 的 setter 後,再使用 useNavigate 轉到別的頁面,讓頁面重新 render。

RemixJS, Cloudflare Pages 專案使用 Google Sign-in

RemixJS, Cloudflare Pages 專案使用 Google Sign-in

承上篇的環境設定,要使用 Google sign-in 但不使用官方套件 google-auth-library

可以使用 worker-auth-providers,它雖然還在 beta 版,但簡單的 oauth 登入是可行的。

GCP 基本設定

Google Cloud Platform (GCP) 設定並下載憑證資料:

  1. 到「API 程式庫」>「公開」>「社交」啟用 Google People API。

  2. 到「API和服務 > 憑證」,點選「+建立憑證」>「OAuth 用戶 ID」。

  3. 「應用程式類型」選「網頁應用程式」

  4. 「已授權的 JavaScript 來源」加入正式環境及測試環境的 URI。

  5. 「已授權的重新導向 URI」加入接收 Query String 參數的頁面 URI,同樣包含測試及正式環境。

  6. 建立完成後,「下載 OAuth 用戶端」的 JSON 檔。

  7. 到「OAuth 同意畫面」,設定應用程式名稱,及測試帳號等。

登入頁面

按了「Google 登入」的按鈕轉到 google-signin.tsx 頁面

// app/routes/google-signin.tsx import { type LoaderFunctionArgs, redirect } from "@remix-run/cloudflare"; import { google } from "worker-auth-providers"; import { getMySession } from "~/utils/sessions"; export async function loader({ request, context, params }: LoaderFunctionArgs) { const session = await getMySession(request); const userId = session.get("userId"); if (userId) { return redirect("/"); } const env: any = context.env; const googleLoginUrl = await google.redirect({ options: { clientId: env.GOOGLE_CLIENT_ID, redirectTo: env.GOOGLE_REDIRECT_URI, }, }); return redirect(googleLoginUrl); }

用戶登入後轉向的頁面

// import { type LoaderFunctionArgs, redirect } from "@remix-run/cloudflare"; import { getMySession, getHeadersWithSetCookie } from "~/utils/sessions"; import { google } from "worker-auth-providers"; import getSupabase from "~/utils/supabase-client"; export async function loader({ request, context, params }: LoaderFunctionArgs) { const env: any = context.env; const { user, tokens } = await google.users({ options: { clientSecret: env.GOOGLE_CLIENT_SECRET, clientId: env.GOOGLE_CLIENT_ID, redirectUrl: env.GOOGLE_REDIRECT_URI, }, request, }); // "user": { // "id": "111******", // "email": "shinder.lin@gmail.com", // "verified_email": true, // "name": "Shinder Lin", // "given_name": "Shinder", // "family_name": "Lin", // "picture": "...", // "locale": "zh-TW" // }, // console.log({ user, tokens }); // tokens: { // access_token: 'ya29...', // expires_in: 3599, // scope: 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid', // token_type: 'Bearer', // id_token: 'eyJh...' // } if (!user?.id) { return redirect("/login"); } const supabase = getSupabase(context); const { data: data1, error: error1 } = await supabase .from("g_users") .select() .eq("gid", user.id); // 是否已經登入過 if (data1.length) { const row: any = data1[0]; if (row.blocked) { return { msg: "您的帳號已被停權" }; } else { await supabase .from("g_users") .update({ updated_at: new Date() }) .eq("gid", row.gid); } } else { const { data: data2, error: error2 } = await supabase .from("g_users") .insert({ gid: user.id, email: user.email, name: user.name }) .select(); if (!data2.length) { return { msg: "DB 無法新增資料" }; } } const session = await getMySession(request); session.set("gid", user.id); session.set("userId", user.email); session.set("nickname", user.name); const headers = await getHeadersWithSetCookie(session); return redirect('/', { headers }); }

2023-11-11

RemixJS, Supabase 專案發佈到 Cloudflare Pages

RemixJS, Supabase 專案發佈到 Cloudflare Pages

注意的事項

條列一下,Remix 從專案建立到發佈到 Cloudflare pages 該注意的事項:

  1. 建立專案時,直接使用 Cloudflare 的官方工具 npm create cloudflare,以方便發佈,參考Deploy a Remix site

  2. 原本由 @remix-run/node 匯入的類型 LinksFunction, LoaderFunctionArgs 或函式 json, redirect 改由 @remix-run/cloudflare 匯入。

  3. 和 NodeJS 相依的套件,儘量不要使用,例如 qs 套件。如果用到了,要在 remox.config.js 裡設定 polyfill。

  4. 開發時,環境變數設定在 .dev.vars 裡。在正式環境時,須到 Cloudflare pages 的專案裡,設定環境變數。

  5. 環境變數的取得,不是透過 process.env,是透過 context.env。而 context 物件是由 LoaderFunctionArgs 或 ActionFunctionArgs ({ request, context, params }) 而來。

在測試的專案用到 bcryptjs,而該套件使用到 Node 的 crypto,因此要如下設定:

// remox.config.js /** @type {import('@remix-run/dev').AppConfig} */ export default { /* 略 */ browserNodeBuiltinsPolyfill: { modules: { crypto: true } }, // bcryptjs 需要 };

Supabase 相關

連線 supabase

// app/utils/supabase-client.ts import { createClient } from "@supabase/supabase-js"; let supabase; export default function getSupabase(context={}){ if(! supabase) { supabase = createClient( context.env.SUPABASE_URL, context.env.SUPABASE_ANON_KEY ); } return supabase; }

新增

const result = await supabase .from("address_book") .insert({ name, email, mobile, birthday, address, creator_gid: myAuth.gid }) .select();

讀取

算數量

let totalRows = 0; const tmpSupabase = supabase .from("address_book") .select("*", { count: "exact", head: true }); if (searchStr) { const { count } = await tmpSupabase.or( `name.like.%${searchStr}%,mobile.like.%${searchStr}%` ); totalRows = count; } else { const { count } = await tmpSupabase; totalRows = count; }

條件搜尋

if (searchStr) { const { data, error } = await supabase .from("address_book") .select() .or(`name.like.%${searchStr}%,mobile.like.%${searchStr}%`) .order("sid", { ascending: false }) .range((page - 1) * perPage, page * perPage - 1); if (!error) { rows = data; } } else { const { data, error } = await supabase .from("address_book") .select() .order("sid", { ascending: false }) .range((page - 1) * perPage, page * perPage - 1); if (!error) { rows = data; } }

更新

let query = supabase .from("address_book") .update({ name, email, mobile, birthday, address }) .eq("sid", sid); let result; if (myAuth.gid === context.env.ADMIN_GID) { result = await query; } else { result = await query.eq("creator_gid", myAuth.gid); }

刪除

let result; let query = supabase.from("address_book").delete().eq("sid", sid); if (myAuth.gid === context.env.ADMIN_GID) { result = await query; } else { result = await query.eq("creator_gid", myAuth.gid); }

2023-10-26

RemixJS 2 保有登入狀態

RemixJS 2 保有登入狀態

呈上篇 RemixJS 2 以 session 實作登入機制,session 的資料存放在 cookie 裡,那要如何在頁面內判斷及保有登入的狀態?

在前端可以用原本 react 的處理方式,以 context 來保有狀態。在後端必須每個 request 都要檢查 cookie。

get-auth.ts 是將解讀 cookie 的功能寫成模組,用來判斷是否為登入狀態。

// app/modules/get-auth.ts import { getMySession } from "~/modules/sessions"; async function getAuth(request: Request) { const session = await getMySession(request); const userId = session.get("userId") || ""; const nickname = session.get("nickname") || ""; return { userId, nickname, auth: !!userId }; // auth 屬性用來判斷是否登入 } export default getAuth;

AuthContextProvider

// app/contexts/AuthContext.tsx import React, { createContext } from "react"; export type AuthDataType = { userId: string | undefined; nickname: string | undefined; }; export const AuthContext = createContext<AuthDataType>({ userId: "", nickname: "", }); type PropsType = { userId: string | undefined; nickname: string | undefined; children: React.ReactNode; }; export function AuthContextProvider({ userId, nickname, children }: PropsType) { return ( <AuthContext.Provider value={{ userId, nickname }}> {children} </AuthContext.Provider> ); }

<AuthContextProvider> 包住 <Outlet />

// app/root.tsx 部份內容 export async function loader({ request, params }: LoaderFunctionArgs) { console.log("App loader"); return await getAuth(request); } export default function App() { const loaderData = useLoaderData<typeof loader>() return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <Meta /> <Links /> </head> <body> <AuthContextProvider userId={loaderData.userId} nickname={loaderData.nickname}> <Outlet /> </AuthContextProvider> <ScrollRestoration /> <Scripts /> <LiveReload /> </body> </html> ); }

防止未授權而使用功能

這裡要分兩個部份 loader 和 action。loader 因為有階層關係,可以在 Layout 的 loader 做阻擋的動作。

// app/routes/address-book.tsx 的 loader 部份 export async function loader({ request, params }: LoaderFunctionArgs) { console.log("address-book loader"); const myAuth = await getAuth(request); if (!myAuth.auth) { // 沒有登入,轉到登入頁面 return redirect(`/login?u=${request.url}`); } return null; }

action 就必須在每個頁面處理。

export async function action({ request, params }: ActionFunctionArgs) { const myAuth = await getAuth(request); if (!myAuth.auth) { // 沒有登入,轉到登入頁面 return redirect(`/login?u=${request.url}`); } // 略 }

2023-10-23

RemixJS 2 以 session 實作登入機制

RemixJS 2 以 session 實作登入機制

一般是以 cookie 存放 session id,session 資料存放在後端,可以是記憶體、檔案或資料庫。 但如果考慮到分散式架構,記憶體和檔案都是不可行的。

以識別用戶而言,其實資料很少一個變數值即可,其餘想放的資料頂多三四個變數值。此時使用官方範例的 createCookieSessionStorage 是個不錯的作法,將資料直接放在 cookie 並加密即可,當然除了好的加密密碼,cookie 最好設定為 httpOnly。

session 工具

使用官方的範例,同時包裝兩個方法 getMySessiongetHeadersWithSetCookie 省點囉唆的動作。

// app/modules/sessions.ts import { Session, createCookieSessionStorage } from "@remix-run/node"; type SessionData = { userId: string; nickname: string; }; type SessionFlashData = { error: string; }; const { getSession, commitSession, destroySession } = createCookieSessionStorage<SessionData, SessionFlashData>({ cookie: { name: "__session", // domain: "remix.run", // expires: new Date(Date.now() + 60_000), httpOnly: true, // maxAge: 60, path: "/", sameSite: "lax", secrets: ["your_secret_key"], // secure: true, }, }); const getMySession = async (request: Request) => { return await getSession(request.headers.get("Cookie")); }; const getHeadersWithSetCookie = async (session: Session) => { return { "Set-Cookie": await commitSession(session), }; }; export { getSession, commitSession, destroySession, getMySession, getHeadersWithSetCookie, };

登入頁

關於登入頁幾點請注意:

  1. 進入登入頁前,先判斷用戶是否已經登入,若是則轉向別的頁面。
  2. 表單有兩個欄位 email 和 password,email 為 userId。
  3. 帳號和密碼直接寫在程式碼裡是不好的作法,這只是個 demo。
  4. 確定登入後,將兩個值寫入 cookie,分別是 userId 和 nickname。
// app/routes/login.tsx // 略 export async function loader({ request, params }: LoaderFunctionArgs) { const session = await getMySession(request); const userId = session.get("userId"); if (userId) { // 如果已經登入 const uStr = getQueryStringObject(request)["u"]; if (uStr) { return redirect(uStr); } else { return redirect("/"); } } return null; } export default function LoginPage() { // 略 return ( <> <Form method="post" encType="multipart/form-data"> {/* 略 */} <button type="submit" className="btn btn-primary"> 登入 </button> </Form> </> ); } type loginUser = { email: string; nickname: string; password: string }; export async function action({ request, params }: ActionFunctionArgs) { const users: loginUser[] = [ { email: "lsd0125@gmail.com", password: "12345678", nickname: "小嘟" }, { email: "shin@gmail.com", password: "345678", nickname: "肥肥" }, ]; const session = await getMySession(request); const body = await getBodyObject(request); const { email, password } = body; const theUser = users.find((u) => { return u.email === email && u.password === password; }); const output = { success: !!theUser, bodyData: body, }; if (theUser) { session.set("userId", email); session.set("nickname", theUser.nickname); const headers = await getHeadersWithSetCookie(session); return json(output, { headers }); } return output; }

登出功能

登出是直接把兩個值從 cookie 中移除。

// app/routes/logout.tsx import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; import { getMySession, getHeadersWithSetCookie } from "~/modules/sessions"; import { getQueryStringObject } from "~/modules/handle-request-data"; export async function loader({ request, params }: LoaderFunctionArgs) { const session = await getMySession(request); session.unset("userId"); session.unset("nickname"); const headers = await getHeadersWithSetCookie(session); const uStr = getQueryStringObject(request)["u"]; if(uStr){ return redirect(uStr, { headers }); } else { return redirect("/", { headers }); } }

有 userId 後,其它要存放在後端的資料,就可以存到資料庫裡了,不要把太多資料放在 cookie 裡,通常不要超過 4K。

RemixJS 2 的 .env

RemixJS 2 的 .env

Remix 在 開發環境 是直接支援 .env 的。

但要特別注意,在 production 時是忽略 .env 的。變通的方式很多,可以用 shell script 或 dotenv-cli 工具。

2023-10-22

RemixJS 2 action 函式

RemixJS 2 action 函式

loader() 的功能是在以 GET 拜訪時,於後端執行,通常是取得頁面要使用的資料。action() 則是用來處理 GET 方法以外的要求。

實作 API

在沒有頁面的 component function,action() 和 loader() 可以用來建立 RESTful API。以下是個刪除功能的例子。

// app/modules/mysql-connect.ts // 用以建立資料庫連線 import mysql, { PoolOptions } from "mysql2/promise"; const options: PoolOptions = { host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, enableKeepAlive: true, keepAliveInitialDelay: 0, }; const pool = mysql.createPool(options); export default pool;
// app/routes/address-book.delete.$sid.tsx import { type ActionFunctionArgs } from "@remix-run/node"; import db from "./../modules/mysql-connect"; export async function action({ request, params }: ActionFunctionArgs) { const output = { success: false, result: {}, }; const sid = params.sid; if (request.method === "DELETE") { if (sid && parseInt(sid)) { const sql = `DELETE FROM address_book WHERE sid=${sid}`; const [result] = await db.query(sql); output.result = result; output.success = true; } } return output; }

搭配頁面表單使用

action() 可以搭配頁面內的表單 (使用 Form 元件) 做回應,在一般情況下是以 AJAX 的方式溝通。以下是一個範例的部份程式碼。

import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node"; import { useLoaderData, Form, useActionData, } from "@remix-run/react"; import { getBodyObject } from "~/modules/handle-request-data"; export async function loader({ request, params }: LoaderFunctionArgs) { // 載入資料 } export default function AddressBookEdit() { const data = useLoaderData(); const actionData = useActionData<typeof action>(); // ... return ( <div className="container"> <Form method="post" encType="multipart/form-data"> <div className="mb-3"> <label htmlFor="email" className="form-label"> email </label> <input type="text" className="form-control" id="email" name="email" value={form.email} onChange={handleFieldChange} /> </div> <button type="submit" className="btn btn-primary"> 修改 </button> </Form> </div> ); } export async function action({ request, params }: ActionFunctionArgs) { const sid = params.sid; const body = await getBodyObject(request); const { name, email, mobile, birthday, address } = body; const output = { success: false, result: {}, bodyData: body, }; // 變更資料 return output; }

注意幾個要點:

  1. 表單必須使用 @remix-run/reactForm 元件建立。
  2. 和一般表單一樣可以使用 encType 屬性決定送出的資料編碼方式。
  3. 可以使用 action 屬性設定表單傳送的對象 (可以不是該頁面的 action() 函式)。
  4. 在頁面內使用 useActionData() 取得表單送出後回傳的訊息或資料。
  5. 若要實作 RESTful API 可以依 request.method 是 POST、PUT 或 DELETE 來做不同的處理。

RemixJS 2 處理從用戶端來的資料

RemixJS 2 處理從用戶端來的資料

handle-request-data.ts 包含了幾個處理要求端來的資料,後來改用 qs 套件比較符合 expressjs 的風格。 其中處理上傳檔案的部份為存成檔案,目前建議存到雲端服務,例如 AWS S3。 這些函式同樣可以使用在 NextJS app router 上。

// *** handle-request-data.ts import qs from "qs"; import fs from "node:fs/promises"; import { v4 as uuidV4 } from "uuid"; export const getQueryStringObject:any = (request: Request) => { const q = request.url.indexOf("?"); if (q < 0) { return {}; } else { return qs.parse(request.url.slice(q+1)); } // *** 以下為使用 Object 的版本 // const url = new URL(request.url); // return Object.fromEntries(url.searchParams); }; export const getBodyObject = async (request: Request) => { const contentType = request.headers.get("Content-Type")?.split(";")[0]; switch (contentType) { case "multipart/form-data": const formData:any = await request.formData(); return qs.parse(new URLSearchParams(formData).toString()); // *** 以下為使用 Object 的版本 // return Object.fromEntries(formData); case "application/x-www-form-urlencoded": const txt = await request.text(); return qs.parse(txt); // *** 以下為使用 Object 的版本 // const usp = new URLSearchParams(txt); // return Object.fromEntries(usp); case "application/json": const json = await request.json(); return json; } }; type FileDataType = { size: number; type: string; lastModified: number; originalName: string; filename: string; path: string; }; type MultipartDataResultType = { fields: { [index: string]: string | string[] }; files: { [index: string]: FileDataType | FileDataType[] }; error: any; }; // *** 處理檔案上傳 export async function getMultipartData( request: Request, acceptedMimeTypes = ["image/jpeg", "image/png"], // 篩選類型設定 useUuidFilename = true, // 使用隨機 uuid 為主檔名 uploadDir = "./tmp" // 上傳的資料夾 ) { let result: MultipartDataResultType = { fields: {}, files: {}, error: {}, }; const method = request.method.toUpperCase(); let type = request.headers.get("Content-Type"); if (!type) return result; type = type.split(";")[0]; // 取得 mimetype if (method !== "GET") { if (type === "multipart/form-data") { const formData = await request.formData(); try { await fs.access(uploadDir, fs.constants.F_OK); } catch (ex) { // 建立上傳的資料夾 await fs.mkdir(uploadDir, { recursive: true }); } for (const [k, v] of formData.entries()) { console.log({ k, v }); if (typeof v === "string") { // 處理文字欄位 if (!result.fields[k]) { result.fields[k] = v; } else { if (result.fields[k] instanceof Array) { const strArray = result.fields[k] as string[]; strArray.push(v); } else { const val = result.fields[k] as string; result.fields[k] = [val, v]; } } } else if (v instanceof Blob) { // 處理檔案欄位 result.files = result.files || {}; const { size, type, name, lastModified } = v; if (acceptedMimeTypes.length) { if (!acceptedMimeTypes.includes(type)) { continue; } let filename = name; if (useUuidFilename) { let tmpName = name.toLowerCase(); let mainName = uuidV4(); let extName = ""; if (tmpName.indexOf(".") !== -1) { const frs = tmpName.split("."); extName = "." + frs[frs.length - 1]; } filename = mainName + extName; } const path = uploadDir + "/" + filename; const fileData: FileDataType = { size, type, lastModified, originalName: name, filename, path, }; if (!result.files[k]) { result.files[k] = fileData; } else { if (result.files[k] instanceof Array) { const fdArray = result.files[k] as FileDataType[]; fdArray.push(fileData); } else { const val = result.files[k] as FileDataType; result.files[k] = [val, fileData]; } } await fs.writeFile(path, v.stream() as any); } } } } } return result; }

RemixJS 2 裡的 link 和 meta 標籤

RemixJS 2 裡的 link 和 meta 標籤

SSR 的特色是可以讓每個頁面擁有各自的 SEO 設定,同時也可以有各自的 link 和 meta 設定。

可以在 root.tsx 使用 links() 設定整個網站都通用的 link 標籤。

export const links: LinksFunction = () => [ ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css", }, { rel: "stylesheet", href: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css", }, ];

在 Layout 架構中,links() 的功能是堆疊起來的,以下式的架構來說明。

app/root.tsx app/routes/address-book.tsx app/routes/address-book.add.tsx

/address-book/add 的頁面最後取得的 link 標籤會依序是三個檔加起來的設定。

meta 標籤

meta 標籤的運作則和 link 標籤不同,在 root.tsx 可以直接設定全站的 meta 標籤。 <Meta /> 是用來載入子元件的 meta 設定。

// root.tsx 片段 export default function App() { const loaderData = useLoaderData<typeof loader>() return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <Meta /> <Links /> </head> <body> <Outlet /> <ScrollRestoration /> <Scripts /> <LiveReload /> </body> </html> ); }

但在 Layout 的結構中,meta() 並不會疊加之前的設定,而是以最後設定的為主。 另外,在同一個檔案 loader() 會比 meta() 先執行,所以 meta() 可以收到 loader() 回傳的資料。

export const meta: MetaFunction = ({ data, params, location, matches }) => { console.log("address-book meta"); console.log(data); // { shin: "der" } return [ { title: "通訊錄" }, { name: "keyword", content: "address-book" }, { name: "name", content: "shin" }, ]; }; export async function loader({ request, params }: LoaderFunctionArgs) { console.log("address-book loader"); return { shin: "der" }; }

RemixJS 2 loader 函式

RemixJS 2 loader 函式

RemixJS 使用 react ,是架構簡潔的 SSR (Server Side Render) 框架。 它的每個頁面都有可能是 SSR 或 CSR (Client Side Render),所以有特殊的兩個後端執行的函式 loader()action(),頁面內的 components 則無。

loader() 的功能是在以 GET 拜訪時,於後端執行,通常是取得頁面要使用的資料。頁面裡 React component 可以使用 useLoaderData() hook 載入 loader() 回傳的資料。

loader() 並非必要的函式。另外,若沒有要回傳資料必須回傳 null。

// app/routes/address-book.edit.$sid.tsx import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import dayjs from "dayjs"; import db from "~/modules/mysql-connect"; import { RowDataPacket } from "mysql2/promise"; export async function loader({ request, params }: LoaderFunctionArgs) { const sid = params.sid; const sql = `SELECT * FROM address_book WHERE sid=?`; const [rows] = await db.query<RowDataPacket[]>(sql, [sid]); if (rows.length) { rows[0].birthday = dayjs(rows[0].birthday).format("YYYY-MM-DD"); return rows[0]; // useLoaderData() 可以取得此處回傳的資料 } else { return redirect("/address-book"); } } export default function AddressBookEdit() { const data = useLoaderData(); return ( <div className="container"> </div> ); }

Layout 的 loader()

每層的 Layout 都有可能需要個別的資料呈現,所以每層的 loader() 會依序觸發。以檔案 app/routes/address-book.add.tsx 為例。依序會觸發下列檔案的 loader()。

app/root.tsx app/routes/address-book.tsx app/routes/address-book.add.tsx

然而,依邏輯 Layout 不應該有 action(),就算有也不會依機制呼叫。

API功能 (沒有頁面函式時)

如果該路由不是要呈現 HTML 頁面,而是提供 JSON 格式的資料時,可以只定義 loader() 而不定義頁面 react component 函式。

import { type LoaderFunctionArgs, json, } from "@remix-run/node"; export async function loader({ request, params }: LoaderFunctionArgs) { return json({ name: "Shinder", method: request.method }); }

2023-10-21

RemixJS 2 的路由架構

RemixJS 2 的路由架構

NestJS 13 推出 app router 架構快一年了,把原本 "資料夾" 和 "檔案" 設定並存的方式,大刀闊斧改成以資料夾為主的路由設定方式。

反之,RemixJS 2 則是轉向以 "檔案" 為主的路由設定方式。如果以中小型網站的角度去看,RemixJS 似乎是比較方便的做法。以下是整理官方文件的說明。

以檔案為基本架構

檔案資料夾結構

app/ ├── routes/ │ ├── _index.tsx │ ├── about.tsx │ ├── concerts._index.tsx │ ├── concerts.$city.tsx │ ├── concerts.trending.tsx │ └── concerts.tsx └── root.tsx
URL路徑 對應的檔案 Layout (佈局檔)
/ app/routes/_index.tsx app/root.tsx
/about app/routes/about.tsx app/root.tsx
/concerts app/routes/concerts._index.tsx app/routes/concerts.tsx
/concerts/trending app/routes/concerts.trending.tsx app/routes/concerts.tsx
/concerts/salt-lake-city app/routes/concerts.$city.tsx app/routes/concerts.tsx

歸納幾點規則:

  1. 根目錄檔案為 _index.tsx
  2. 檔案名稱中以點「.」做為分隔的符號,表示路徑的分段。 concerts.trending.tsx 表示 /concerts.trending 路徑。
  3. 動態路由使用美金符號為分段開頭,例如上表中的 concerts.$city.tsx
  4. 經過的佈局檔,會由 root.tsx 為最開頭。以 app/routes/concerts.trending.tsx 為例,經過的佈局檔會為 root.tsx 再來才是 concerts.tsx。佈局檔和葉路由檔會分別被編譯為個別的 js 檔以方便動態載入。

下表為更複雜的狀況:

URL路徑 對應的檔案
/address-book/edit/abc app/routes/address-book.edit.abc.tsx
/address-book/edit/123 app/routes/address-book.edit.$sid.tsx
/address-book/edit/abc/def app/routes/address-book.edit.abc.def.tsx

以資料夾為架構

也可以使用資料夾為架構,不過只能在 routes 目錄下的第一層,更內層的資料夾就沒有效果了。 資料夾的命名規則同上述的檔案命名規則,而對應的頁面檔案名稱需為 route.tsx

app/ ├── routes/ │ ├── _index/ │ │ ├── signup-form.tsx │ │ └── route.tsx │ ├── about/ │ │ ├── header.tsx │ │ └── route.tsx │ ├── concerts/ │ │ ├── favorites-cookie.ts │ │ └── route.tsx │ ├── concerts.$city/ │ │ └── route.tsx │ ├── concerts._index/ │ │ ├── featured.tsx │ │ └── route.tsx │ └── concerts.trending/ │ ├── card.tsx │ ├── route.tsx │ └── sponsored.tsx └── root.tsx

自訂路由

一般使用預設路由規則即可,在某些情況下要自訂路由對應可以設定 remix.config.js 檔,請參考官方文件說明。

2023-09-04

NextJS app router 實作 body-parser 功能

NextJS app router 實作 body-parser 功能

承上篇,NextJS 的 app router 所使用的 request 和 response 分別是 NextRequest 和 NextResponse 類型;又分別是 JS 裡 Request 和 Response 類型的延伸。所以解析 http body 要遵循 Request 和 Response 類型的 api 用法。

底下的工具,借用 qs 套件的功能解析 urlencoded 的格式。實作了三個功能:

  1. getQueryString(): 解析 query string parameters
  2. getBodyData(): 解析表單資料(包含 multipart 格式)
  3. getMultipartData(): 解析上傳的表單
// *** request-parser.js import qs from "qs"; import fs from "node:fs/promises"; import { v4 as uuidV4 } from "uuid"; /** 解析 query string */ export function getQueryString(request) { return qs.parse(request.url.split("?")[1]); } /** 解析 urlencoded, json, multipart */ export async function getBodyData(request) { let result = {}; const method = request.method.toUpperCase(); const type = request.headers.get("Content-Type"); if (method !== "GET") { if (type === "application/x-www-form-urlencoded") { const txt = await request.text(); result = txt.length ? qs.parse(txt) : {}; } else if (type === "application/json") { try { result = await request.json(); } catch (ex) {} } else { const { fields } = await getMultipartData(request); if (fields) result = { ...fields }; } } return result; } /** 解析 multipart */ export async function getMultipartData( request, // NextRequest 物件 acceptedMimetypes = [], // 篩選類型設定,預設為不篩選 useUuidFilename = true, // 使用隨機 uuid 為主檔名 uploadDir = "./tmp" // 上傳的資料夾 ) { let result = { fields: undefined, files: undefined, error: undefined, }; const method = request.method.toUpperCase(); let type = request.headers.get("Content-Type"); if (!type) return result; type = type.split(";")[0]; // 取得 mimetype if (method !== "GET") { if (type === "multipart/form-data") { const formData = await request.formData(); try { await fs.access(uploadDir, fs.constants.F_OK); } catch (ex) { // 建立上傳的資料夾 await fs.mkdir(uploadDir, { recursive: true }); } for (const [k, v] of formData.entries()) { if (typeof v === "string") { // 處理文字欄位 result.fields = result.fields || {}; if (!result.fields[k]) { result.fields[k] = v; } else { if (result.fields[k] instanceof Array) { result.fields[k].push(v); } else { result.fields[k] = [result.fields[k], v]; } } } else if (v instanceof Blob) { // 處理檔案欄位 result.files = result.files || {}; const { size, type, name, lastModified } = v; if (acceptedMimetypes.length) { if (!acceptedMimetypes.includes(type)) { continue; } let filename = name; if (useUuidFilename) { let tmpName = name.toLowerCase(); let mainName = uuidV4(); let extName = ""; if (tmpName.indexOf(".") !== -1) { const frs = tmpName.split("."); extName = "." + frs[frs.length - 1]; } filename = mainName + extName; } const path = uploadDir + "/" + filename; const fileData = { size, type, lastModified, originalName: name, filename, path, }; if (!result.files[k]) { result.files[k] = fileData; } else { if (result.files[k] instanceof Array) { result.files[k].push(fileData); } else { result.files[k] = [result.files[k], fileData]; } } await fs.writeFile(path, v.stream()); } } } } } return result; }

以下是在 route.js 中的用法範例。

import { getBodyData, getQueryString, getMultipartData, } from "@/modules/request-parser"; const responseInit = { status: 200, headers: { "Content-Type": "application/json", }, } export async function GET(request) { let output = getQueryString(request); return new Response(JSON.stringify(output), responseInit); } export async function POST(request) { let body = await getBodyData(request); return new Response(JSON.stringify(body), responseInit); } export async function PUT(request) { let multi = await getMultipartData(request, ["image/jpeg"]); return new Response(JSON.stringify(multi), responseInit); }

2023-09-03

NextJS app router 實作 session 功能

NextJS app router 實作 session 功能

NextJS 的 app router 所使用的 request 和 response 分別是 NextRequest 和 NextResponse 類型;又分別是 JS 裡 Request 和 Response 類型的延伸。而不像 NodeJS 裡的 IncomingMessage 和 ServerResponse,有著彈性的功能。

比較大的問題是,ExpressJS 裡的 request 和 response 是從頭「流」到尾的機制。我們可以很方便在 middlewares 處理完資料,掛載資料到 request 或 response.locals 的屬性上。

然而 NextJS 的 middleware 卻不是這樣運作的,request 物件可能是透過 clone 的方式再往下一個階段傳遞。所以在 middleware 取得的 request 和在 route.js 裡拿到的 request 是不相同(不同參照)的物件。如此就無法藉由 request 物件來傳遞資料。沒有接收 response 物件的情況下,更難使用 response 物件。

後來才找到 Passing data from middleware to API route,利用 middleware 的 response.rewrite(),將資料放在 cookie 裡,往 end-point 傳遞。

shin-session-obj.js 為實作取得 session 物件的 getSessionObj() 函式。回傳值中若 new_session_id 不為空字串,表示要設定新的 session-id。為了簡明,裡面用 Object 來實作 session 的功能,當然你也可以 DB 或 files 來實作,不過就不在此說明。

// modules/shin-session-obj.js import { validate, v4 as uuidV4 } from "uuid"; let sessions; export function getSessionObj(request) { let new_session_id = ""; if (!request) return {}; // 沒有 request 時 let sid = request.cookies.get("shin-session-id")?.value; // 沒有 session_id 時, 或 session_id 不是 uuid 時, 設定新的 session-id if (!sid || !validate(sid)) { new_session_id = uuidV4(); sid = new_session_id; console.log({new_session_id}) } // 初始化 sessions if (!sessions) { sessions = {}; } // 初始化 session if (!sessions[sid]) { sessions[sid] = {}; } return { session: sessions[sid], new_session_id, }; }

在 middleware 會呼叫 getSessionObj() 以測試是否取得 new_session_id。若取得,則在複製的 request 物件裡設定 cookie 內容為我們要傳下傳遞的 session-id;同時,在 response 物件內設定要用戶端儲存的 session-id(儲存於 cookie)。

import { NextResponse } from "next/server"; import { getSessionObj } from "@/modules/shin-session-obj"; export async function middleware(nextRequest, nextFetchEvent) { const {session, new_session_id} = getSessionObj(nextRequest); if(new_session_id) { const clonedRequest = nextRequest.clone(); clonedRequest.headers.append("Cookie", `shin-session-id=${new_session_id}`); const response = NextResponse.rewrite(nextRequest.url.toString(), { request: clonedRequest, }); response.cookies.set({ name: "shin-session-id", value: new_session_id, path: "/", }); return response; } }

在任何的 route.js 就可以直接使用 getSessionObj() 取得對應的 session 物件。

import { getSessionObj } from "@/modules/shin-session-obj"; export async function GET(request) { const {session} = getSessionObj(request); if(session){ session.count = session.count || 0; session.count++; } const response = new Response(JSON.stringify({session}), { status: 200, headers: { "Content-Type": "application/json", }, }); return response; }

要注意的是此實作,主要是為了說明實作方式,在正式環境使用記憶體存放 session 資料有許多問題。最好還是以 DB 或檔案來存放 session 資料。

2023-04-08

建立 fastapi 專案開發環境

建立 fastapi 專案開發環境

這篇主要是建立基本環境,和執行環境的說明。

安裝所需套件

pip install fastapi pip install uvicorn # ASGI server pip install gunicorn # WSGI server

在專案中撰寫測試的程式:

from fastapi import FastAPI app = FastAPI() @app.get('/hello') def hello(): return {"say": "hello"}

直接使用 uvicorn 執行:

uvicorn main:app --reload --port 8888

執行後可以在 http://localhost:8888/docs 看到 openAPI document。

gnuicorn 搭配 uvicorn 執行:

gunicorn main:app -b 0.0.0.0:8888 -k uvicorn.workers.UvicornWorker # 下式為背景執行 gunicorn main:app -b 0.0.0.0:8888 -k uvicorn.workers.UvicornWorker --daemon # 背景執行時若要停下來,可以使用下式查看 pid 再使用 kill 移除 proccess ps -ef | grep gunicorn

如果環境中有安裝 PM2 (Nodejs 行程管理工具) 也可以使用 PM2 啟動行程。

pm2 --name=gunicorn start "gunicorn main:app -b 0.0.0.0:8888 -k uvicorn.workers.UvicornWorker"

2023-04-07

使用 conda 建立 python 虛擬環境

使用 conda 建立 python 虛擬環境

查看所有的虛擬環境

conda env list

使用預設的 python 版本建立環境,下式的 myenv 為虛擬環境的名稱。

conda create -n myenv

使用指定的 python 版本建立環境。

conda create -n myenv python=3.10

啟用某個環境

conda activate myenv # conda deactivate #退出某個環境 pip install --upgrade pip # 確保 pip 是最新的版本

安裝套件

pip install fastapi pip install uvicorn # pip install python-multipart # uvicorn 安裝時會自動安裝 python-multipart

2023-03-26

將 React app 和 Node.js 寫的 API 放在同一台伺服器

將 React app 和 Node.js 寫的 API 放在同一台伺服器

要前後端分離,同時要將 React app 和 API 放在同一台伺服器相同的 port,以避免誇來源的問題。

此時可以用一台 Node express server 解決。

React app 發佈至 Express server 靜態資料夾

第一種方式是,把發佈後的 React 放在 build 資料夾(別的名稱也可以),並將 build 設定為靜態資料夾。

其餘後端的 API 可以定義在服務靜態 html 檔之前。

const express = require("express"); const fs = require("fs/promises"); const app = express(); app.use(express.static("build")); let html = ""; fs.readFile(__dirname + "/build/index.html").then((txt) => { html = txt.toString(); }); // app.use('/api', YOUR_ROUTER); // 可以把 api 掛在這裡 app.use("/api", (req, res) => { res.json({ name: "shinder", say: "hello" }); }); app.use((req, res) => { res.send(html); }); const port = 3005; app.listen(port, () => { console.log(`啟動 ${port}`); });

使用反向代理伺服器

另一種做法是使用反向代理伺服器,可以利用 http-proxy-middleware 套件,建立 proxy。

const express = require("express"); const { createProxyMiddleware } = require("http-proxy-middleware"); const app = express(); app.use(express.static("build")); // app.use('/api', YOUR_ROUTER); // 可以把 api 掛在這裡 app.use("/api", (req, res) => { res.json({ name: "shinder", say: "hello" }); }); app.use( "/", createProxyMiddleware({ target: "http://localhost:3000", changeOrigin: true, }) ); const port = 3005; app.listen(port, () => { console.log(`啟動 ${port}`); });

2023-02-10

MacOS Ventura ssh 問題

Mac mini 升級 Ventura 第一個發現的災難,無法使用原先設定好的 auth key 自動登入

基本上是修改設定檔:
sudo nano /etc/ssh/ssh_config

在設定檔後加入這兩行:
HostkeyAlgorithms +ssh-rsa
PubkeyAcceptedAlgorithms +ssh-rsa

FB 留言