Backend oRPC + Effect + Typescript Tutorial


ก่อนหน้านี้เมื่อตอนต้นปี 2025 ผมได้จัด workshop ที่สร้าง Backend โดยใช้ Hono + Effect ไปแล้ว
ตอนนั้นใช้ Layered architecture แต่ต่อมาโปรเจคที่ทำปัจจุบันนี้ ก็จะมีคนที่ทำ Backend แค่ 1-2 คนเท่านั้นเอง การใช้โครงสร้างแบบนั้นจะเป็นอุปสรรคมากกว่าเมื่อเทียบกับความเร็วในการทำงาน ทำผลลัพธ์ที่ได้มันออกมาช้า เพราะต้องทำเผื่อเพื่อนร่วมทีม ต้องกำหนด interface เยอะแยะ เพื่อที่จะได้ทำงานร่วมกันกับทีมได้ตรงตาม requirements แต่หลายครั้ง “ทีม” ที่ว่าก็มีแค่คนเดียวหรือสองคนเท่านั้นเอง
ฉนั้น blog นี้ก็เลยจะใช้โครงสร้างที่มันง่ายขึ้น เข้าใจง่ายขึ้นออกของได้ไวขึ้นครับ โดยรอบนี้จะใช้วิธีการ infer type เอาเลย เอา Abtraction ต่างๆออกไปก่อน ข้ามการกำหนด interface ไปเลย แต่ยังใช้ Effect อยู่นะ ซึ่งก็เป็นการ trade-off ละนะ
What is oRPC
ถ้าใครรู้จัก tRPC ตัว oRPC ก็จะคล้ายกันมากๆเลย
oRPC ทำอะไรได้บ้าง
-
End-to-End type safety:
oRPC จะเป็น tool ที่ใช้ทำ End-to-End type-safe API ทำให้ client รู้ว่าเวลาจะเรียกใช้ api จะรู้ได้เลยว่าต้องส่งอะไรไปให้ server บ้าง และรู้ได้เลยว่าจะได้รับ response อะไรกลับมาบ้าง รวมถึง Error ที่อาจจะเกิดขึ้นด้วยครับ ✨
-
OpenAPI supported by default:
รองรับ OpenAPI โดย default เลยครับ 📚 ตรงนี้ tRPC ไม่สามารถทำได้ แต่ก็มีคนทำ lib ที่สามารถสร้าง OpenAPI doc จาก tRPC routes แต่ lib ตัวนั้นก็ไม่ได้มีการ maintain แล้ว
-
Various Runtime and Framework supported
oRPC สามารถ integrate เข้ากับ Runtime ได้หลายตัวเช่น Bun, Node , Deno, Cloudflare worker แบบไม่มี framework อื่นเลย หรือจะใช้คู่กับ Framework อื่นๆด้วยก็ได้ เช่น Elysia, Hono , Express, NextJS , Tanstack start, Solid start , React Router framework โดยไม่ว่าจะเป็น Runtime ตัวไหน หรือ Framework ตัวไหนการ setup ก็จะคล้ายกันมากๆ ทำให้เราสามารถเปลี่ยน Framework หรือ Runtime ได้ตามต้องการเลยครับ กลายเป็น Framework agnostic ไปเลย (แต่ก็ยังผูกกับ oRPC ฮ่าๆ) 🔄
-
Standard schema supported: อันนี้ผมชอบมาก oRPC รองรับ Standard schema ด้วยครับ ซึ่งทำให้เราสามารถใช้ runtime type validator ตัวไหนก็ได้ จะใช้ Zod, Arktype, Valibot หรือ Effect Schema ใช้ตัวที่ชอบได้เลย 🛡️ ที่ผมชอบตรงนี้เพราะว่า หลายๆ framework จะ supported Zod ซะส่วนใหญ่ หรือ Elysia ก็จะใช้ typebox ซึ่งผมอยากใช้ Effect Schema ไง พอเป็น Standard Schema แล้วทำให้เราใช้ Effect Schema ได้เลยโดยอัตโนมัติ
-
Native type supported: oRPC จะรองรับการใช้งาน Blob, File, URL, Date object, Set, Map ตรงนี้ถ้าใครเคยใช้ tRPC น่าจะเคยเจอว่าเราไม่สามารถ upload file ผ่าน tRPC ได้ หรือถ้าใน response data เป็น Map หรือ Date object เราต้องแปลงมันเป็น json ก่อน ซึ่ง object พวกนี้มันแปลงเป็น json ไม่ได้ ก็ต้องใช้ร่วมกับ Superjson ซึ่งก็ไม่ได้ทำได้ทั้งหมด ใน oRPC จะยืดหยุ่นกว่ามาก 📂 ถ้าใครยังมี data type ที่ไม่ได้ support เราสามารถสร้าง custom serializer มาช่วยได้ด้วย ตัวอย่างเช่น ผมใช้ Effect Schema ซึ่ง จะมี Option (Some or None) ผมก็แค่ใช้ Effect Schema decode() & encode() เพื่อทำ custom serializer ได้เลย ทำให้คนส่งและคนรับเห็น Option ได้เหมือนกัน
-
server-side procedures มันคืออะไร มันเหมือนกับการที่เราสร้าง REST api แล้วเอา axios เรียก API ตัวเองจาก backend ตัวเองนี่แหละ ฟีลลิ่งประมาณนั้นครับ oRPC ทำให้เราเรียกใช้งาน API ได้เหมือนกับเรียกใช้ function ที่ไม่ต้องผ่าน proxy ไม่ต้องผ่าน axios อะไรทำนองนั้นครับ ตรงนี้ผมไม่ได้ใช้เพราะว่า ทำแค่ Backend ถ้าจะเรียกตัวเองก็ไปเรียก function ในส่วนของ Services ดีกว่า แต่ก็มี use case นึงที่ผมนึกออกครับ คือ เราใช้ Tanstack start หรือ Nextjs ทำ fullstack ครับ คือเขียนทั้ง frontend และ backend ใน framework เดียว พอทำ SSR กับ Tanstack start เวลาเปิดหน้าเวป มันก็จะไปดึง data ผ่าน fetch, axios โดยวิ่งไปที่
/api
เพื่อไปเอา data มา render หน้าเวปก่อนจะ response ไปที่ browser ซึ่งมันผ่าน HTTP protocal ก่อน ทำให้มี latency เพิ่มขึ้นเล็กน้อย การใช้ server-side procedure นี้ก็จะทำให้ไม่มี overhead แบบนั้นครับ ใครมี use cases อื่นๆก็บอกกันด้วยนะ 😁🙏🏼
What are we gonna do?
เราจะทำโปรเจคง่ายๆกันนะครับ เพราะว่าเราจะ focus ที่โครงสร้างและ oRPC นะครับ
เราจะทำ Task CRUD ง่ายๆ พร้อมกับทำ Caching ด้วย Redis
สร้าง presigned link สำหรับ upload file ด้วยการใช้ MinIO ครับ
ส่วน Task จะเก็บลง Postgres database นะครับ
ทั้งหมดนี้จะใช้ oRPC framework เป็นหลัก ใช้คู่กับ Hono อีกทีนึงครับ
ผมเตรียม docker compose file มาให้แล้ว สำหรับ Postgres, Redis, MinIO
Setup project
โดยใน blog นี้ผมจะใช้
initialize project
mkdir orpc-backendcd orpc-backendgit initbun init
จะได้แล้วก็ให้เลือกตัวเลือกแบบนี้
❯ bun init
? Select a project template - Press return to submit. Blank React❯ Library
package name (orpc-backend): orpc-backendentry point (src/index.ts): src/index.ts
เสร็จแล้ว terminal ก็น่าจะขึ้นแบบนี้
❯ bun init
✓ Select a project template: Librarypackage name (orpc-backend): orpc-backendentry point (src/index.ts): src/index.ts
+ tsconfig.json (for editor autocomplete)
To get started, run:
bun run src/index.ts
bun install v1.2.13 (64ed68c9)
Checked 5 installs across 6 packages (no changes) [12.00ms]
tsconfig.json
ผมจะใส่ baseUrl กับ paths เพิ่มเข้าไป เพื่อที่จะได้เรียก import ได้สะดวกขึ้น
{ "compilerOptions": { // Environment setup & latest features "lib": ["ESNext"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true,
// Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true,
// Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }}
install ESLint
ติดตั้ง eslint กัน
bunx @antfu/eslint-config@latest
ก็เลือกไปตามนี้
❯ bunx @antfu/eslint-config@latest
┌ @antfu/eslint-config v4.13.1fatal: not a git repository (or any of the parent directories): .git│◆ There are uncommitted changes in the current repository, are you sure to continue?│ ● Yes / ○ No◆ Select a framework:│ ◻ Vue│ ◻ React│ ◻ Svelte│ ◻ Astro│ ◻ Solid│ ◻ Slidev│◆ Select a extra utils:│ ◻ Formatter (Use external formatters (Prettier and/or dprint) to format files that ESLint cannot handle yet (.css, .html, etc))│ ◻ UnoCSS◆ Update .vscode/settings.json for better VS Code experience?│ ● Yes / ○ No│◇ Bumping @antfu/eslint-config to v4.13.1│◆ Changes wrote to package.json│◆ Created eslint.config.js│◆ Updated .vscode/settings.json│◆ Setup completed│└ Now you can update the dependencies by run pnpm install and run eslint --fix
ติดตั้ง eslint perfectionist
bun add -D eslint-plugin-perfectionist
จะได้ไฟล์ eslint.config.js
มา
ก็แก้ให้เป็นตามนี้
ตรงนี้ใครอยากเพิ่ม rules อะไรก็ใส่ได้เลย
eslint.config.js
import antfu from "@antfu/eslint-config";import perfectionist from "eslint-plugin-perfectionist";
export default antfu( { // formatters: true, // react: true, stylistic: { indent: 2, quotes: "double", }, typescript: true, }, { plugins: [perfectionist.configs["recommended-alphabetical"]], rules: { "no-console": "warn", "perfectionist/sort-objects": "error", "ts/consistent-type-definitions": "off", "unicorn/throw-new-error": "off", "unused-imports/no-unused-imports": "error", }, }, { ignores: ["build", "**/*.json", ".husky/install.mjs", "src/providers/prisma/generated"], });
Setup dotenvx
ผมจะใช้
recap สั้นๆ .env
โดยเฉพาะ
คือเราต้องมี private key, public key จึงจะสามารถแกะ .env
ที่ encrypt ไว้ได้
พอ encrypt .env
แล้วทำให้เราเอา .env
ขึ้นไปไว้บน git ได้ ทำให้ shared .env
กับทีมได้เลย
พอ encrypt แล้วเราจะได้ .env.keys
มาชุดนึง keys ตัวนี้เราจะไม่เอาขึ้นไปบน git ไม่งั้นๆใครๆก็แกะ .env
ได้หมด
แล้วค่อยส่ง keys ให้เพื่อนร่วมทีมอีกทีนึง ทำให้เวลา .env
มันถูกเพิ่มอะไรเข้ามาเราก็ไม่ต้องไปบอกเพื่อนร่วมทีมแล้ว ขอแค่เขามี keys ก็จะเห็นเลยว่าเราเพิ่มอะไรเข้าไปบ้าง
bun add -D @dotenvx/dotenvx
แล้วก็สร้างไฟล์ .env
, .env.uat
, .env.prod
ด้วยคำสั่ง
touch .env && \touch .env.uat && \touch .env.prod
เราจะได้ env files แบบนี้
.├── .env├── .env.prod├── .env.uat
ใน package.json
ก็ให้เพิ่ม dotenvx scripts แบบนี้
package.json
"scripts": { "prepare": "bun .husky/install.mjs", "dotenvx": "dotenvx", "env:dev:dc": "dotenvx decrypt", "env:dev:ec": "dotenvx encrypt", "env:uat:ec": "dotenvx -f .env.uat encrypt", "env:uat:dc": "dotenvx -f .env.uat decrypt", "env:prod:ec": "dotenvx -f .env.prod encrypt", "env:prod:dc": "dotenvx -f .env.prod decrypt", "env:all:dc": "bun env:dev:dc && bun env:uat:dc && bun env:prod:dc", "env:all:ec": "bun env:dev:ec && bun env:uat:ec && bun env:prod:ec", "dotenvx:example": "bun env:dev:dc && bun run dev", "dotenvx:start": "dotenvx run -- bun start"}
จะมีเยอะหน่อยนะครับ เพราะรวม env ของ 3 environment servers เลย
ec
คือ encrypt
dc
คือ decrypt
เพิ่ม .env.keys
ไปที่ .gitignore
ด้วยนะ
ส่วน .env
เราเอาขึ้น git ได้เลย ก็ลบไปซะ
.gitignore
# dotenv environment variable files.env.env.keys
setup Husky + commitlint + gitmoji
Husky คือ git hook tools ทำให้เราใช้งาน git hook ได้ง่ายขึ้น จะใช้ให้เรียกคำสั่งeslint
ตอนที่ทำ git commit เพื่อให้ไป ตรวจสอบไฟล์ก่อนว่าถูกต้องตามกฏของ eslint หรือเปล่า ถ้าไม่ตรงจะทำให้ git commit ไม่ผ่าน เพื่อทำให้ code บน git น่าเชื่อถือมากขึ้น ทุกๆคนในทีมจะมั่นใจได้ว่า code ที่อยู่บน git มันผ่านการตรวจสอบจาก eslint มาระดับนึงแล้ว
นอกจากนี้ยังสั่งให้เช็ค type ก่อนได้ด้วยนะ
-
gitmoji เป็น tool ที่ช่วยให้เราใส่ emoji ลงไปใน commit msg ได้ -
commitlint เป็น tool ที่ช่วยเช็ค commit msg ว่าตรงตามกฎหรือเปล่า กฏที่ผมจะใช้นั้นจะเป็นกฏของ gitmoji นี่แหละ บังคับรูปแบบของ commit msg เพื่อให้ทีมมีโครงสร้างของ commit msg ไปในทิศทางเดียวกัน
Install Husky
bun add -D husky && bunx husky init
จะมี folder .husky
เพิ่มมาละ
.├── .husky│ ├── _│ └── pre-commit
สร้างไฟล์ .husky/install.mjs
.husky/install.mjs
// Skip Husky install in production and CIif (process.env.NODE_ENV === "production" || process.env.CI === true) { process.exit(0);}try { const husky = (await import("husky")).default; console.log(husky());} catch (e) { if (e.code !== "ERR_MODULE_NOT_FOUND") throw e;}
แล้วไปแก้ prepare script ใน package.json
package.json
{13 collapsed lines
"name": "orpc-backend", "module": "src/index.ts", "type": "module", "devDependencies": { "@antfu/eslint-config": "^4.13.1", "@types/bun": "latest", "eslint": "^9.26.0", "eslint-plugin-perfectionist": "^4.13.0", "husky": "^9.1.7" }, "peerDependencies": { "typescript": "^5" }, "scripts": { "prepare": "bun .husky/install.mjs" }}
encrypt all .env files before commit
ผมต้องการที่จะ encrypt env ทุกๆไฟล์ก่อนที่จะ commit ด้วยครับ
ก็จะเพิ่มคำสั่งไปในไฟล์ pre-commit แบบนี้ครับ
.husky/pre-commit
bun env:all:ecgit add .
จะพักไว้ก่อน
install & setup commitlint
ติดตั้งด้วยคำสั่ง
bun add -D commitlint-config-gitmoji commitlint
สร้างไฟล์ config ที่ชื่อว่า commitlint.config.cjs
commitlint.config.cjs
module.exports = { extends: ["gitmoji"], rules: { "header-max-length": [0, "always", 100], },};
แล้วจะกลับไปที่ husky
.husky/commit-msg
bunx commitlint --edit "$1"
ทีนี้พอเราสั่ง git commit -m "this is commit msg"
ตัว commitlint ก็จะไปเช็ค commit msg ของเราว่าตรงตามกฏของ gitmoji หรือเปล่า
ซึ่งตามตัวอย่างนี้จะไม่ตรงกับกฏของ gitmoji นะครับ จะทำให้ git commit ไม่ผ่าน
แล้วกฏที่ว่าเป็นยังไง ไปดูได้ที่ commitlint-gitmoji
แต่การที่จะเขียนตามกฏด้วยตัวเองนั้นมันก็จำไม่ได้ ผมเลยใช้ตัวช่วยเพื่อให้การเขียน commit msg นั้นเป็นไปตามกฏได้ง่ายขึ้นผ่านการตอบคำถามผ่าน cli
ตัวช่วยที่ว่าก็คือ
Commitizen
ติดตั้งด้วยคำสั่ง
bun add -D commitizen cz-customizable
แล้วก็เพิ่ม script ใน package.json อีก
package.json
"scripts": {12 collapsed lines
"prepare": "bun .husky/install.mjs", "dotenvx": "dotenvx", "env:dev:dc": "dotenvx decrypt", "env:dev:ec": "dotenvx encrypt", "env:uat:ec": "dotenvx -f .env.uat encrypt", "env:uat:dc": "dotenvx -f .env.uat decrypt", "env:prod:ec": "dotenvx -f .env.prod encrypt", "env:prod:dc": "dotenvx -f .env.prod decrypt", "env:all:dc": "bun env:dev:dc && bun env:uat:dc && bun env:prod:dc", "env:all:ec": "bun env:dev:ec && bun env:uat:ec && bun env:prod:ec", "dotenvx:example": "bun env:dev:dc && bun run dev", "dotenvx:start": "dotenvx run -- bun start", "commit": "cz"}
ยังไม่จบนะ เราต้องไปใส่ config เพิ่มใน package.json อีกนะ แบบนี้
package.json
{33 collapsed lines
"name": "orpc-backend", "module": "src/index.ts", "type": "module", "devDependencies": { "@antfu/eslint-config": "^4.13.1", "@dotenvx/dotenvx": "^1.44.0", "@types/bun": "latest", "commitizen": "^4.3.1", "commitlint": "^19.8.1", "commitlint-config-gitmoji": "^2.3.1", "cz-customizable": "^7.4.0", "eslint": "^9.26.0", "eslint-plugin-perfectionist": "^4.13.0", "husky": "^9.1.7" }, "peerDependencies": { "typescript": "^5" }, "scripts": { "prepare": "bun .husky/install.mjs", "dotenvx": "dotenvx", "env:dev:dc": "dotenvx decrypt", "env:dev:ec": "dotenvx encrypt", "env:uat:ec": "dotenvx -f .env.uat encrypt", "env:uat:dc": "dotenvx -f .env.uat decrypt", "env:prod:ec": "dotenvx -f .env.prod encrypt", "env:prod:dc": "dotenvx -f .env.prod decrypt", "env:all:dc": "bun env:dev:dc && bun env:uat:dc && bun env:prod:dc", "env:all:ec": "bun env:dev:ec && bun env:uat:ec && bun env:prod:ec", "dotenvx:example": "bun env:dev:dc && bun run dev", "dotenvx:start": "dotenvx run -- bun start", "commit": "cz" }, "config": { "commitizen": { "path": "node_modules/cz-customizable" }, "cz-customizable": { "config": "./cz.config.cjs" } }}
จะเห็นว่าชี้ไปที่ config file cz.config.cjs
ซึ่งเรากำลังจะสร้างในขั้นตอนต่อไปนี่แหละ
cz.config.cjs
module.exports = { types: [ { value: ":sparkles: feat", name: "✨ feat:\tAdding a new feature" }, { value: ":bug: fix", name: "🐛 fix:\tFixing a bug" }, { value: ":memo: docs", name: "📝 docs:\tAdd or update documentation" }, { value: ":lipstick: style", name: "💄 style:\tAdd or update styles, ui or ux", }, { value: ":recycle: refactor", name: "♻️ refactor:\tCode change that neither fixes a bug nor adds a feature", }, { value: ":zap: perf", name: "⚡️ perf:\tCode change that improves performance", }, { value: ":white_check_mark: test", name: "✅ test:\tAdding tests cases", }, { value: ":truck: chore", name: "🚚 chore:\tChanges to the build process or auxiliary tools\n\t\tand libraries such as documentation generation", }, { value: ":rewind: revert", name: "⏪️ revert:\tRevert to a commit" }, { value: ":construction: wip", name: "🚧 wip:\tWork in progress" }, { value: ":construction_worker: build", name: "👷 build:\tAdd or update regards to build process", }, { value: ":green_heart: ci", name: "💚 ci:\tAdd or update regards to build process", }, ],
scopes: [ { name: "backend" }, { name: "admin" }, { name: "web" }, { name: "database" }, { name: "ci" }, { name: "cd" }, { name: "docker" }, { name: "general" }, ],
usePreparedCommit: false, // to re-use commit from ./.git/COMMIT_EDITMSG allowTicketNumber: false, isTicketNumberRequired: false, ticketNumberPrefix: "TICKET-", ticketNumberRegExp: "\\d{1,5}",
// it needs to match the value for field type. Eg.: 'fix' /* scopeOverrides: { fix: [
{name: 'merge'}, {name: 'style'}, {name: 'e2eTest'}, {name: 'unitTest'} ] }, */ // override the messages, defaults are as follows messages: { type: "Select the type of change that you're committing:", scope: "\nDenote the SCOPE of this change (optional):", // used if allowCustomScopes is true customScope: "Denote the SCOPE of this change:", subject: "Write a SHORT, IMPERATIVE tense description of the change:\n", body: 'Provide a LONGER description of the change (optional). Use "|" to break new line:\n', breaking: "List any BREAKING CHANGES (optional):\n", footer: "List any ISSUES CLOSED by this change (optional). E.g.: #31, #34:\n", confirmCommit: "Are you sure you want to proceed with the commit above?", },
allowCustomScopes: true, allowBreakingChanges: ["feat", "fix"], // skip any questions you want // skipQuestions: ['scope', 'body'],
// limit subject length subjectLimit: 100, // breaklineChar: '|', // It is supported for fields body and footer. // footerPrefix : 'ISSUES CLOSED:' // askForBreakingChangeFirst : true, // default is false};
config ด้านบนผมก็ copy มาจาก commitizen ครับ เพื่อนๆสามารถแก้ไขได้ตามชอบ ส่วนตัวผมก็ประมาณนี้ละกัน ที่จะเปลี่ยนก็ scope
ละนะ
setup จบแล้วครับสำหรับ Husky + gitmoji
เวลาใช้งาน ก็จะต้องสั่งแบบนี้
git add .bun commit
เราจะไม่ได้สั่ง git commit -m ""
นะครับ ให้ไปใช้ script ใน package.json แทน
ไม่อย่างนั้นเราต้องใส่ commit msg เองด้วยมือ ซึ่งปกติแล้วผมจะจำไม่ได้ว่ากฏมันเป็นยังไง เพราะมีตัวช่วยแล้วอะนะ
setup lint staged
พอเราสั่ง commit code ใส่ commit msg เรียบร้อยแล้ว ก็จะให้ husky ไปรันคำสั่ง eslint --fix .
เพื่อให้ไปไล่เช็คทุกๆไฟล์ว่าถูกต้องตามกฏของ eslint หรือเปล่า
ถ้าไม่ถูกต้องเราก็จะไม่ยอมให้ commit ก็ต้องไปแก้ให้เรียบร้อยซะก่อน
ทีนี้ถ้าเกิดว่างานเราแก้แค่ 1-2 ไฟล์ เราจะต้องไปไล่เช็คทุกๆไฟล์ ก็คงจะเกินความจำเป็น
ตรงนี้ผมก็จะใช้
ติดตั้งด้วยคำสั่ง
bun add -D lint-staged
สร้าง config file ที่ชื่อว่า lint-staged.config.mjs
lint-staged.config.mjs
const config = { "*.{js,ts,jsx,tsx}": "eslint .",};
export default config;
แล้วก็ไปแก้ script ใน package.json ให้เป็นแบบนี้
package.json
"scripts": { "prepare": "bun .husky/install.mjs", "dotenvx": "dotenvx", "env:dev:dc": "dotenvx decrypt", "env:dev:ec": "dotenvx encrypt", "env:uat:ec": "dotenvx -f .env.uat encrypt", "env:uat:dc": "dotenvx -f .env.uat decrypt", "env:prod:ec": "dotenvx -f .env.prod encrypt", "env:prod:dc": "dotenvx -f .env.prod decrypt", "env:all:dc": "bun env:dev:dc && bun env:uat:dc && bun env:prod:dc", "env:all:ec": "bun env:dev:ec && bun env:uat:ec && bun env:prod:ec", "dotenvx:example": "bun env:dev:dc && bun run dev", "dotenvx:start": "dotenvx run -- bun start", "commit": "lint-staged && cz"},
Setup global env
ถ้าเราอยากให้ typescript รู้ว่าเรามี env อะไรบ้างแบบมี auto complete อะ
เราจะต้อง setup เองเพิ่มเติม
สร้างไฟล์ global.d.ts
ไว้ที่ root folder ก็ได้
global.d.ts
/* eslint-disable @typescript-eslint/no-empty-object-type */interface Env { PORT: string;}
declare global { namespace NodeJS { interface ProcessEnv extends Env { NODE_ENV: "development" | "production" | "test" | "uat"; } }}
export {};export type IEnv = Env;
เรามี env อะไรก็ใส่ไปที่ interface Env {}
ได้เลย
ในตัวอย่างผมใส่ PORT: string
เข้าไป
ที่เห็นว่าใส่ NODE_ENV
อีกที่นึงคือจะใส่ตรงนั้นก็ได้แหละ แค่เผื่อไว้ว่าจะเอา type Env
ไปใช้ทำอะไรต่อ แต่ก็ไม่เคยได้เอาไปทำอะไรนะ
ที่นี้เวลาเราเรียกใช้ process.env
ก็จะมี auto complete ละ ทำให้คนในทีมรู้ได้ด้วยว่ามี env อะไรให้ใช้บ้าง
ถ้าใน Bun ก็เรียก Bun.env
แทนได้เลย
Example project
project ที่จะทำจะเป็นโปรเจคง่ายๆเลย อยากให้เข้าใจง่ายๆไปเร็วๆ ก็เลยจะทำ Task manager app
โดยเก็บ data ผ่าน Prisma ไปเก็บที่ Postgres
Caching ด้วย Redis โดยจะใช้
attached file โดยใช้ MinIO,
โดยในแต่ละ Task ผมอยากให้แนบไฟล์ได้ด้วยนะครับ โดยจะเก็บเป็น link ส่วนไฟล์จะอยู่ที่ MinIO ครับ
Docker compose for all services
ผมเตรียม docker compose มาให้แล้ว สำหรับ
เพิ่ม folder data ไปที่ .gitignore
ก่อนเลย
.gitignore
dev-services/data
dev-services/compose.yaml
services: postgres: image: postgres container_name: db restart: unless-stopped user: postgres volumes: - ./data/postgres:/var/lib/postgresql/data environment: - POSTGRES_DB=tasks - POSTGRES_PASSWORD=mysecretpassword - POSTGRES_USER=postgres ports: - 5432:5432 healthcheck: test: [CMD, pg_isready, -U, postgres] interval: 10s timeout: 5s retries: 5 redis: image: redis:8-alpine container_name: redis restart: unless-stopped volumes: - ./data/redis/cache:/data ports: - 6379:6379 healthcheck: test: [CMD, redis-cli, --raw, incr, ping] interval: 10s timeout: 5s retries: 5 redis-insight: image: redis/redisinsight:latest container_name: redisinsight environment: - RI_APP_PORT=5540 - RI_APP_HOST=0.0.0.0 # - RI_PROXY_PATH=use when do reverse proxy - RI_REDIS_HOST=redis - RI_REDIS_PORT=6379 # - RI_REDIS_USERNAME= # - RI_REDIS_PASSWORD= ports: - 5540:5540 volumes: - ./data/redis-insight:/data healthcheck: test: [CMD, curl, --fail, http://localhost:5540/api/health] interval: 10s timeout: 5s retries: 5 minio: image: minio/minio command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER=${AWS_ACCESS_KEY_ID:-minio} - MINIO_ROOT_PASSWORD=${AWS_SECRET_ACCESS_KEY:-miniosecretkey} volumes: - ./data/minio:/data ports: - "9000:9000" - "9001:9001" healthcheck: test: [CMD, /usr/bin/mc, ready, local] interval: 10s timeout: 5s retries: 5
postgres
สำหรับ postgres จะมี config แบบนี้
- database name:
tasks
- password:
mysecretpassword
- user:
postgres
- port:
5432
- DATABASE_URL:
postgresql://postgres:mysecretpassword@localhost:5432/tasks?schema=public
Redis & UI
- host: localhost
- port:
6379
- address: เปิด Admin UI browser ด้วย link นี้ Redis insight
Minio
- host: localhost
- port:
9000
- address: เปิด MinIO console UI ใน browser ด้วย link นี้ MinIO Console
แล้วก็สั่ง start services ได้เลยด้วยคำสั่ง
docker compose up -d
Folder structure
มาดูการวางไฟล์และโฟลเดอร์ของโปรเจคนี้กันก่อน


อันนี้เป็น files & folders จากโปรเจคตัวอย่างที่จะทำกันหลังจากนี้
src├── build.ts├── bun.lock├── commitlint.config.cjs├── cz.config.cjs├── dev-services│ ├── compose.yaml│ └── data├── eslint.config.js├── global.d.ts├── lint-staged.config.mjs├── mise.toml├── package.json├── prisma│ └── schema.prisma├── README.md├── src│ ├── app.ts│ ├── features│ │ ├── index.ts│ │ ├── task-files│ │ │ ├── taskFile.errors.ts│ │ │ ├── taskFile.route.ts│ │ │ ├── taskFile.schema.ts│ │ │ └── taskFile.service.ts│ │ └── tasks│ │ ├── task.errors.ts│ │ ├── task.route.ts│ │ ├── task.schema.ts│ │ └── task.service.ts│ ├── providers│ │ ├── minio│ │ │ ├── minio.errors.ts│ │ │ ├── minio.provider.ts│ │ │ └── minio.schema.ts│ │ ├── prisma│ │ │ ├── generated│ │ │ └── prisma.provider.ts│ │ └── redis│ │ ├── redis.errors.ts│ │ └── redis.provider.ts│ └── shared│ ├── config│ │ └── index.ts│ ├── effect│ │ ├── exit-helpers.ts│ │ ├── helpers.ts│ │ └── index.ts│ ├── orpc│ │ ├── base.ts│ │ ├── handlers.ts│ │ ├── jsonSchema.ts│ │ ├── middlewares│ │ │ └── cache.middleware.ts│ │ └── plugins│ │ ├── cors.ts│ │ ├── csrf.ts│ │ ├── index.ts│ │ ├── openapi-reference.ts│ │ └── response-headers.ts│ └── runtime│ └── index.ts└── tsconfig.json
Dependencies in this project
lib ที่เราจะใช้ใน project นี้ ทั้งหมดเลย ก็ตามนี้
bun add @orpc/client @orpc/openai @orpc/server @prisma/client effect hono minio prisma redis
Setup build script
เรามา setup build script กันก่อน
ซึ่งใน .ts
แล้วใส่ build config ในไฟล์ .ts
นั้นได้เลย
ในที่นี้ผมจะสร้างไฟล์ build.ts
ไว้ใน root folder นะครับ
แล้วก็มี config แบบนี้
build.ts
import process from "node:process"
Bun.build({ bytecode: false, entrypoints: ["./src/app.ts"], format: "esm", minify: true, outdir: "./dist", target: "bun",}).then((result) => { if (result.success === false) { console.error("build failed", result.logs) process.exit(1) }})
จาก config ด้านบน จะสั่งให้ Bun ไปเริ่ม build ที่ไฟล์ app.ts
แล้วจะ output ไปที่ folder dist
ส่วนอื่นๆก็จะเป็น
format: esm
คือให้ file ที่ได้จากการ build มี format เป็น esm ซึ่งตรงนี้เราไม่ได้ทำ Library ก็ไม่ต้องสนใจก็ได้minify: true
คือทำให้ไฟล์มีขนาดเล็กให้ได้มากที่สุดโดยการลบ space, comment, newline, tab และแก้ชื่อตัวแปรให้สั้นๆ ทำให้เราอ่านไม่ออก แต่ app ก็ยังทำงานได้ปกติ
แล้วก็เพิ่ม script build ใน package.json
ด้วยนะครับ
package.json
"scripts": {14 collapsed lines
"prepare": "bun .husky/install.mjs", "dotenvx": "dotenvx", "env:dev:dc": "dotenvx decrypt", "env:dev:ec": "dotenvx encrypt", "env:uat:ec": "dotenvx -f .env.uat encrypt", "env:uat:dc": "dotenvx -f .env.uat decrypt", "env:prod:ec": "dotenvx -f .env.prod encrypt", "env:prod:dc": "dotenvx -f .env.prod decrypt", "env:all:dc": "bun env:dev:dc && bun env:uat:dc && bun env:prod:dc", "env:all:ec": "bun env:dev:ec && bun env:uat:ec && bun env:prod:ec", "dotenvx:example": "bun env:dev:dc && bun run dev", "dotenvx:start": "dotenvx run -- bun start", "commit": "lint-staged && cz", "dev": "dotenvx run -- bun --watch src/app.ts", "build": "bun build.ts" },
เวลาจะ build ให้ สั่ง
bun run build
จะสั่ง bun build
เฉยๆไม่ได้นะ ต้องมี run ด้วย เพราะคำสั่ง script มันไปซ้ำกับ command build
ของ bun
Setup Prisma
ติดตั้ง prisma
bun add prismabunx prisma init --datasource-provider postgresql
เราจะได้ folder prisma แบบนี้
src└─ prisma └── schema.prisma
ใน schema.prisma
ก็จะใส่ model แบบนี้
prisma/schema.prisma
generator client { provider = "prisma-client-js" output = "../src/providers/prisma/generated"}
datasource db { provider = "postgresql" url = env("DATABASE_URL")}
model Task { id Int @id @default(autoincrement()) title String desc String @default("") files File[]}
model File { id Int @id @default(autoincrement()) storagePath String Task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) taskId Int}
จาก schema.prisma
ด้านบน
ต้องการให้ prisma ไปสร้าง table Task
กับ File
ซึ่งมี relation แบบ One-to-Many ตรงนี้ไม่ขอลงลึกนะ แค่เป็นตัวอย่างเฉยๆ
ส่วนตอนที่ให้ prisma generate
จะให้เอา folder ที่ generate ได้ไปไว้ใน src/providers/prisma/generated
ข้างใน folder นี้จะมี prisma client มาให้เรียบร้อยเลย เราไม่ต้องติดตั้ง package @prisma/client
ละ
แล้วก็สั่ง
bunx prisma db pushbunx prisma generate
เบื้องต้นตอนนี้เรายังอยู่ในช่วง development ผมก็เลยใช้ prisma db push
ไปก่อนเลย เผื่อว่า table เรายังมีการเปลี่ยนแปลงเพิ่มเติม
ถ้าหากว่าน่าจะไม่เปลี่ยนแล้ว ก็จะสั่ง prisma migrate dev
ในตอนนั้น
เราจะได้ files และ folders เพิ่มเข้ามาที่ providers/
แบบนี้
src/providers└── prisma ├── generated/
Install Effect
ผมจะใช้ Effect ในทุกๆ project เหมือนเดิมครับ ไปติดตั้งกันเลย
bun add effect
Effect มี functions เยอะมาก ผมก็จะมี function ที่เรียกใช้บ่อยๆ ก็เลยจะทำ Helper functions เอาไว้ให้เรียกใช้ได้ง่ายขึ้นอีก
ไปสร้าง Effect helper functions กันเลย
src/shared/effect/helpers.ts
import * as S from "effect/Schema"
export type ErrorMsg = { error?: unknown msg?: string}export function createErrorFactory<T>(Self: new (payload: ErrorMsg) => T) { return (msg?: string) => (error?: unknown) => new Self({ error, msg })}
export function convertFrom<A, I, R>(schema: S.Schema<A, I, R>) { return { fromObjectToSchemaEffect: S.decode(schema), fromSchemaToObjectEffect: S.encode(schema), }}
แต่ละตัวคืออะไรกันบ้าง
ErrorMsg
คือ Error message อยากให้หน้าตาแบบไหน ผมก็อยากได้แค่ error object กับ message แค่นั้นเลยcreateErrorFactor()
เป็น function ที่ช่วยให้สร้าง error class ได้ง่ายๆ เดี๋ยวจะได้ใช้เยอะเลย ถ้าตอนนี้ยังไม่เข้าใจ เดี๋ยวตอนใช้งานก็จะเข้าใจเองครับconvertFrom()
เป็น helper function ที่ผมเอาไว้ใช้กับ Effect Schema ครับ เนื่องจากว่า พอเรามี Effect Schema แล้ว แล้วต้องการแปลงจาก data type ไปเป็น Effect Schema หรือจาก Effect Schema ไปเป็น data type
(Effect Schema สามารถแปลงกลับไปกลับมาได้) เขาใช้decode()
กับencode()
ซึ่งผมมักจะงง ทุกทีเวลาใช้งาน ก็เลยตั้งชื่อใหม่แค่นั้นแหละครับ ฮ่าๆ
ถ้าแปลง data type ไปเป็น Effect Schema ไม่สำเร็จเราจะได้ errorParseError
มา
ถัดมา ยังมี Helper อีกตัวหนึ่งคือ Exit
helpers
Exit เป็น monad ตัวนึงใน Effect มักจะใช้ตอนสุดท้ายของ program เพื่อไม่ให้ program crash หรือ throw error เราจะใช้ Exit มาช่วย
ก็เลยจะมี Exit helpers แบบนี้
src/shared/effect/exit-helpers.ts
import { Cause, Exit } from "effect"
export function getDataOrThrowRawError<A, E = never>(exit: Exit.Exit<A, E>) { return Exit.match(exit, { onFailure: (error) => { const err = Cause.squash(error) throw err }, onSuccess: data => data, })}
getDataOrThrowRawError()
เนื่องจากว่า Exit ใช้เพื่อป้องกันไม่ให้ program crash มันจะเอา Error ที่เกิดขึ้นมาครอบด้วย Cause
ครับ
ซึ่งถ้าเราอยากได้ Error ตัวจริง จะต้องแกะมันด้วย Cause.squash()
นะครับ
ซึ่ง function getDataOrThrowRawError()
ก็จะแกะ Error ออกมา แล้ว throw error เลย
ที่ต้องทำแบบนี้เพราะ oRPC ต้องการให้ throw error นะครับ
เดี๋ยวในส่วนของ oRPC จะได้ใช้งาน น่าจะเข้าใจมากขึ้น ณ ตรงนั้นครับ
สุดท้ายผมจะ export Helpers ทั้งสองตัวที่ index แบบนี้
src/shared/effect/index.ts
export * as ExitHelpers from "./exit-helpers"export * as EffectHelpers from "./helpers"
ตอนนี้ที่ folder src/shared/effect/
ก็จะมี files แบบนี้
src └── shared ├── effect ├── exit-helpers.ts ├── helpers.ts └── index.ts
Config Envs
ผมจะทำ env validator โดยใช้ Effect Schema ครับ
คือ ENV มันก็สำคัญในการที่เราจะ deploy app ไปยัง production หรือ develop บน local machine ของเรา
ถ้าเกิดว่าลืม ENV บางตัวก็จะทำให้ app crash หรือไม่ทำงานอย่างที่คาดหวัง
ดังนั้นเป็นต้นทางที่ดีที่สุดในการที่จะ validate ENV กันเลยก่อนที่ app จะ start ขึ้นมา
ดังนั้นเราจะสร้างไฟล์ config.ts
ใน folder src/shared/config/
ไว้เก็บ config ของ ENV
src/shared/config/index.ts
import { Match } from "effect"import * as S from "effect/Schema"
const EnvSchema = S.Struct({ CORS_ORIGIN: S.String.pipe( S.transform(S.Array(S.String), { decode: a => a.split(",").map(url => url.trim()), encode: i => i.join(","), }), ), DATABASE_URL: S.String, MINIO_ACCESS_KEY: S.String.pipe(S.nonEmptyString()), MINIO_ENDPOINT: S.String.pipe(S.nonEmptyString()), MINIO_PORT: S.NumberFromString.pipe(S.optionalWith({ default: () => 9000 })), MINIO_SECRET_KEY: S.String.pipe(S.nonEmptyString()), MINIO_USE_SSL: S.Boolean.pipe(S.optionalWith({ default: () => false })), NODE_ENV: S.Literal("development", "production", "test", "uat").pipe(S.optionalWith({ default: () => "development" })), PORT: S.NumberFromString.pipe(S.optionalWith({ default: () => 3333 })), REDIS_URL: S.String.pipe(S.nonEmptyString()), SELF_URL: S.String.pipe(S.nonEmptyString()),})
export type EnvEncoded = typeof EnvSchema.Encoded
export function getEnvs() { const config = S.decodeSync(EnvSchema)(Bun.env) return { ...config, MINIO_SERVER_URL: Match.value(config.MINIO_USE_SSL).pipe( Match.when(true, () => `https://${config.MINIO_ENDPOINT}:${config.MINIO_PORT}`), Match.when(false, () => `http://${config.MINIO_ENDPOINT}:${config.MINIO_PORT}`), Match.exhaustive, ), }}
ผมจะค่อยๆอธิบายนะครับ
สร้าง Env schema ด้วย Effect schema S.Struct()
ครับ
มี key อะไรก็ใส่เข้ามาตรงนี้ได้เลย
-
CORS_ORIGIN
ผมอยากให้ระบุ cors origin ผ่าน env และสามาถมีได้หลาย origin ก็ให้ใช้,
คั่นได้เลย เดี๋ยว schema จะแกะออกมา แล้วจะได้เป็น array ของ string ครับ -
PORT
เนื่องจากว่า port จะต้องเป็นตัวเลข แต่ว่าใน.env
จะระบุเป็น string ได้อย่างเดียวเท่านั้น ก็เลยให้ Schema แปลงให้เลยเช่นกัน โดยใช้S.NumberFromString
นั่นเอง
ส่วนอื่นๆก็ไม่ได้มีอะไรเป็นพิเศษ
ผมก็มี env config ของ database, minio, redis อย่างที่เห็นครับ -
EnvEncoded
คือ type ปกติ ของ Schema ครับ เป็น type ของ typescript ทั่วไปครับ ไม่ใช่ type ของ Effect schema ครับ
เดี๋ยวอันนี้จะเอาไปใช้ที่ global type ด้วยครับ เดี๋ยวพาทำอีกที -
getEnvs()
นี่คือ function ที่จะเอา env ที่เราได้จากBun.env
แล้ว validate แล้ว return เป็น object ของ env
พอได้มาแล้วผมจะเอา config ทั้งหลายของ MinIO มาต่อกันเป็น MinIO server url ด้วย เพื่อที่จะได้เรียกใช้ได้ง่ายๆต่อไปภายหลังครับ
ก่อนที่เราจะ start app ถ้ามีการเรียกgetEnvs()
แล้ว env เรามีไม่ครบ ตัวdecodeSync()
จะ throw error แล้วจบเลย ไปไม่ถึงจุดที่ start app พร้อมทั้งบอกว่า env อะไรที่ขาดหายไป
config global type
เราจะมาทำ global type สำหรับ env กันครับ
เวลาเรียก Bun.env
หรือ process.env
จะได้มี type บอก มี auto complete แสดงว่ามี env อะไรให้ใช้บ้างพร้อมทั้ง type ของมัน
ให้สร้างไฟล์ global.d.ts
ไว้ด้านนอกสุดเลย
global.d.ts
import type { EnvEncoded } from "@/shared/config"
interface Env extends EnvEncoded {}
declare global { namespace NodeJS { interface ProcessEnv extends Env { } }}
export { }export type IEnv = Env
จะเห็นว่าเราให้มัน extends EnvEncoded
ก็เลยทำให้ auto complete ของ env ทั้งหมดใน project นี้จะเป็น type ของ EnvEncoded
ที่มาจาก Schema ของเราอีกทีนึง
Prisma provider
มาทำ prisma provider กันด้วยการใช้ Effect.Service
src/providers/prisma/prisma.provider.ts
import { Data, Effect } from "effect"import { getEnvs } from "@/shared/config"import { EffectHelpers } from "@/shared/effect"import { PrismaClient } from "./generated"
export class PrismaConnectError extends Data.TaggedError("Prisma/Connect/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class PrismaDisconnectError extends Data.TaggedError("Prisma/Disconnect/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class PrismaProvider extends Effect.Service<PrismaProvider>()("Service/Prisma", { dependencies: [], effect: Effect.gen(function* () { const config = getEnvs() const prismaClient = new PrismaClient({ datasourceUrl: config.DATABASE_URL, })
const connect = (pc: PrismaClient) => Effect.tryPromise({ catch: PrismaConnectError.new(), try: () => pc.$connect(), })
const disconnect = (pc: PrismaClient) => Effect.tryPromise({ catch: PrismaDisconnectError.new(), try: () => pc.$disconnect(), })
yield* connect(prismaClient)
return { connect, disconnect, prismaClient, } }),}) {}
ผมจะค่อยๆอธิบายนะครับ
-
มาดูส่วน
PrismaConnectError
กันก่อน
เวลาใช้ prisma ก็อาจจะต้องเรียกconnect()
ด้วย ซึ่งมันเป็นPromise
ครับ พอเป็นPromise
แล้วเกิด error เราก็จะแยกแยะไม่ออกละว่า Error คืออะไร ผมก็เลยเอา Effect มาครอบไว้ แล้วกำหนด Error ให้มัน เป็นPrismaConnectError
ก็เลยสร้าง Error class ที่ชื่อว่าPrismaConnectError
ขึ้นมาผ่านData.TaggedError
ซึ่ง tag ตรงนี้ต้องไม่ซ้ำกับ Error อื่นๆในระบบนะครับ ผมก็จะใส่ tag แบบนี้"PrismaConnectError"
โดย Error class นี้จะเก็บ data ที่มี type เป็นEffectHelpers.ErrorMsg
ที่เราได้ทำไว้ก่อนหน้านี้แล้วใน Effect helpers นั่นเอง
ภายใน class มี static method ที่ชื่อว่าnew()
ซึ่งผมก็เอา Helper function ที่เราสร้างไว้ใน Effect helpers มาใช้ เช่นกัน ทำให้เวลาผมอยากสร้าง class error อันนี้ ก็จะเรียกPrismaConnectError.new()
ก็จะได้ class error นี้ ทำให้เขียนสั้นลงอีกหน่อย
ถ้าอยากใส่ message ก็ใส่ลงไปใน methodnew()
ได้เลย เช่นPrismaConnectError.new("Prisma connect error please recheck your database connection")
-
PrismaDisconnectError
ก็ทำเหมือนกัน
หลังจากนี้ ถ้ามีส่วนไหนที่เป็นPromise
ผมจะทำ Error class แยกออกมาแบบนี้ทั้งหมดเลยครับ
ทำให้เราสามารถรู้ได้ในระดับ Type Level เลยว่าถ้าเรียก function นี้จะต้องเจอกับ Error อะไรบ้าง -
สร้าง
PrismaProvider
class ด้วยการใช้Effect.Service
ครับ อันนี้เป็น api ตัวใหม่ของ Effect ที่สามารถใช้แทนที่Context
api ได้เลย ทำให้เราทำ Dependencies injection ได้ง่ายขึ้นครับ
การใช้งานคือให้
class ชื่อ_service extends Effect.Service<ชื่อ class ของเรา>()("ชื่อ tag ที่ห้ามซ้ำกับ Service อื่นๆในระบบ", {})
ส่วนใน {}
จะเป็นจุดที่เราจะสร้าง functions ต่างๆของ service ครับ ซึ่งจะอยู่ใน key: effect
อีกทีนึง แนะนำให้ใช้ Effect.gen(function* () {})
นะครับ จะทำให้เราเขียน function ของ service ได้ง่ายขึ้นมากขึ้นครับ
ตัวอย่างก็ตาม code ด้านบนเลย
สุดท้ายเราอยากจะให้คนอื่นเรียกใช้ function อะไรได้บ้างก็ให้ return ออกไปครับ
สุดท้ายอีกที Service ตัวนี้เป็น class นะครับ ต้องอย่าลืม {}
ที่อยู่ด้านนอกสุดด้วยนะ มันไม่ได้มี method อะไรหรอกแต่ต้องใส่ด้วย ไม่งั้นจะงงว่า error อะไรนะ ผมนี่ลืมประจำเลย ฮ่าๆๆ
บางคนอาจจะไม่คุ้นเคยกับ function* () {}
ซึ่งมันเป็น generator function นะครับ การใช้งานก็จะคล้ายๆกับการใช้ async-await เลย แค่เปลี่ยนจาก await
เป็น yield*
แทน
เราจะใช้ yield*
ก็ต่อเมื่อเรียกใช้ function ที่ถูกครอบด้วย Effect
เช่น connect()
ถูกครอบด้วย Effect.tryPromise
ก็เลยต้องใช้ yield* connect(prismaClient)
นั่นเอง
Services
เราจะมาเขียน Services กันเลย
ตรงนี้จะถือว่า Prisma รับงานในส่วนของ Repository ไปหมดแล้ว
ถ้าอยากเปลี่ยน Database ก็ยังพอทำได้ ถ้า Prisma supported Database ตัวใหม่ตัวนั้น แต่โดยมากก็ไม่ได้เปลี่ยน Database กันหรอก
เพื่อความง่ายก็จะมาสร้าง services เลย
เพื่อความรวดเร็วในการ Dev เราจะทำแค่ Services
และจะไม่ใส่ Abstraction อย่าง Interface ด้วย อย่างที่บอกไปก่อนหน้าแล้วว่างานส่วนของ Backend ตอนนี้จะมี Dev มาทำแค่ 1-2 คน ต่อ 1 project ก็เลยเอา Abtraction ออกไป
เมื่อไร product มันเริ่มขยาย ต้องการให้มี developers มาช่วยทำมากขึ้นก็จะเริ่มใส่ Abtraction กับ Contract มากำกับอีกทีนึงในภายหลัง
ตรงนี้ถ้าใครอยากทำตามก็ต้องแล้วแต่ team ของเพื่อนๆด้วยนะ อย่าไปทำโดยไม่ถามทีมก่อนนะครับ เดี๋ยวจะซวยเอาได้
หรือแค่ทดลองเล่นๆดูก่อนก็จัดไปได้ครับ
เราจะมาทำ Task service กันก่อนนะครับ ก็จะล้อไปกับ Table ที่เรามีน่ะแหละ ง่ายๆก่อนครับ
สุดท้ายเราจะมี folder & files แบบนี้นะครับ
├── src│ ├── app.ts│ ├── features│ │ ├── index.ts│ │ └── tasks│ │ ├── task.errors.ts│ │ ├── task.route.ts│ │ ├── task.schema.ts│ │ └── task.service.ts
มาดูกันก่อนว่าเราจะวาง Services ไว้ตรงไหนของในโปรเจคของเรา
จะสร้าง service ของ Task และ File ไว้ใน src/features/tasks/task.service.ts
ผมทำ Dependency injection ด้วย Effect ก็เลยจะสร้าง services ผ่าน Effect นะครับ เดี๋ยวไปดูกันว่าจะทำอย่างไร
แต่ก่อนจะเขียน Service ก็จะสร้าง Schema ก่อนนะครับ
Task Schema
Schema ตัวนี้จะเป็น runtime type validator ครับ หลังจากนี้ data ที่จะไหลไปในระบบ ก็จะถูกกำหนดให้มีหน้าตาตาม Schema นี้เท่านั้น
src/features/tasks/task.schema.ts
/* eslint-disable ts/no-redeclare */import * as S from "effect/Schema"import { EffectHelpers } from "@/shared/effect"
export const TaskId = S.Number.pipe(S.brand("TaskId"))export type TaskId = typeof TaskId.Type
export const TaskSchema = S.Struct({ desc: S.String, id: TaskId, title: S.String,})export const TaskSchemaStd = S.standardSchemaV1(TaskSchema)export type TaskSchema = typeof TaskSchema.Typeexport type TaskEncoded = typeof TaskSchema.Encodedexport const taskSchemaConvertor = EffectHelpers.convertFrom(TaskSchema)
/** * Schema definition for an array of Tasks */export const TaskArraySchema = S.Array(TaskSchema)export const TaskArraySchemaStd = S.standardSchemaV1(TaskArraySchema)export type TaskArraySchema = typeof TaskArraySchema.Typeexport type TaskArrayEncoded = typeof TaskArraySchema.Encodedexport const taskSchemaArrayConvertor = EffectHelpers.convertFrom(TaskArraySchema)
/** * Schema definition for creating a new Task * Omits the 'id' field since it will be generated on creation */export const TaskCreateSchema = TaskSchema.omit("id")export const TaskCreateSchemaStd = S.standardSchemaV1(TaskCreateSchema)export type TaskCreateSchema = typeof TaskCreateSchema.Typeexport type TaskCreateEncoded = typeof TaskCreateSchema.Encodedexport const taskSChemaCreateConvertor = EffectHelpers.convertFrom(TaskCreateSchema)
จากด้านบน ทำหลายอย่างเลย
- สร้าง
TaskId
ที่เป็น branded type ของnumber
นะครับ เพื่อให้การใช้งานnumber
ปกติไม่สามารถแทนที่TaskId
ได้ - สร้าง
TaskSchema
ที่เป็น Schema ของTask
นะครับ - มีการ แปลง
TaskSchema
ให้เป็นTaskSchemaStd
ด้วย คือแปลงจาก Effect Schema ให้ไปเป็น Standard Schema ครับ เนื่องจากว่า oRPC ยังไม่ได้ support Effect Schema แต่ว่า supported Standard Schema แล้ว ก็เลยทำเผื่อไว้เลยตรงนี้ - สร้าง type alias ของ
TaskSchema
ด้วยtypeof TaskSchema.Type
และtypeof TaskSchema.Encoded
ไว้ให้ใช้งานได้ง่ายๆ - สร้าง function
taskSchemaConvertor
ที่เอาไว้แปลง Schema กลับไปกลับมา โดยใช้ Helper function ที่ได้ทำไปแล้ว ก่อนหน้านี้ - ทำ Schema ของ
TaskArraySchema
และTaskCreateSchema
ด้วย Pattern เดิมเลย
task.service.ts
ทีนี้มาทำ Service กัน
โดยจะทำ CRUD functions สำหรับ Task แบบง่ายๆนี่แหละครับ
create()
getById(taskId: TaskId)
getAll: (config: { page: number; itemsPerPage: number; })
update: (id: TaskId, data: TaskCreateSchema)
deleteById: (id: TaskId)
task.service.ts
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/client"import type { TaskCreateSchema, TaskId } from "./task.schema"import { Effect } from "effect"import { NoSuchElementException } from "effect/Cause"import { PrismaProvider } from "@/providers/prisma/prisma.provider"import { TaskCreateError, TaskDeleteError, TaskGetAllError, TaskGetByIdError } from "./task.errors"import { taskSchemaArrayConvertor, taskSchemaConvertor } from "./task.schema"
export class TaskService extends Effect.Service<TaskService>()("Service/Task", { dependencies: [ PrismaProvider.Default, ], effect: Effect.gen(function* () { const { prismaClient: pc } = yield* PrismaProvider
const create = (data: TaskCreateSchema) => Effect.tryPromise({ catch: TaskCreateError.new(), try: () => pc.task.create({ data, }), }).pipe( Effect.andThen(taskSchemaConvertor.fromObjectToSchemaEffect), )
const getById = (id: TaskId) => Effect.tryPromise({ catch: TaskGetByIdError.new(), try: () => pc.task.findUnique({ where: { id, }, }), }).pipe( Effect.andThen(Effect.fromNullable), Effect.andThen(taskSchemaConvertor.fromObjectToSchemaEffect), )
const getAll = (config: { page: number, itemsPerPage: number }) => Effect.tryPromise({ catch: TaskGetAllError.new(), try: () => pc.task.findMany({ skip: (config.page - 1) * config.itemsPerPage, take: config.itemsPerPage, }), }).pipe( Effect.andThen(taskSchemaArrayConvertor.fromObjectToSchemaEffect), )
const update = (id: TaskId, data: TaskCreateSchema) => Effect.tryPromise({ catch: TaskCreateError.new(), try: () => pc.task.update({ data, where: { id, }, }), }).pipe( Effect.andThen(taskSchemaConvertor.fromObjectToSchemaEffect), )
const deleteById = (id: TaskId) => Effect.tryPromise({ catch: (e) => { const err = e as PrismaClientKnownRequestError if (err.code === "P2025") { return new NoSuchElementException() } return TaskDeleteError.new()(e) }, try: () => pc.task.delete({ where: { id, }, }), }).pipe( Effect.andThen(taskSchemaConvertor.fromObjectToSchemaEffect), )
return { create, deleteById, getAll, getById, update, } }),}) { }
ก็จะค่อยๆอธิบายนะครับ
เราสร้าง TaskService
class ด้วย Effect.Service
เหมือนกับที่ทำที่ Prisma provider เลยครับ
แต่ในรอบนี้จะเห็นว่ามี dependencies
ที่เป็น [PrismaProvider.Default]
ด้วย
คือใน TaskService
นี้ เราจะใช้ PrismaProvider
ใน Service นี้ด้วย
เราก็เลยใส่ PrismaProvider.Default
เข้าไปใน dependencies
ของ TaskService
นี้
เวลาเรียกใช้ TaskService
ใน เราก็จะไม่ต้อง provide PrismaProvider
เองแล้ว เพราะใส่ไว้ตรงเรียบร้อยแล้ว
แต่ก็ยังสามารถเรียกใช้ TaskService
แบบไม่มี Dependencies ได้ด้วย ซึ่งเราจะต้องใช้ TaskService
แบบไม่มี dependencies ตอนที่เขียน unit test นั่นเอง
โดยให้เรียกใช้ TaskService.DefaultWithoutDependencies
ก็จะไม่มี PrismaProvider.Default
ติดไปด้วย
ส่วนในการทำงานปกติก็จะใช้ TaskService.Default
นั่นเอง ได้เลย มันจะมี PrismaProvider.Default
ติดไปด้วยเลย
ส่วนการใช้ prisma ใน TaskService
นี้ จะใช้ yield* PrismaProvider
ในการ get prisma client มาใช้งาน
จากนั้นก็สร้าง function create
, getById
, getAll
, update
, deleteById
functions เหล่านี้ทำงานยังไงก็ตาม code เลย
และยังมีส่วนของ Errors ที่ยังไม่ได้ทำ
ก็จะมาทำกันต่อจากนี้แหละ
task.errors.ts
สร้าง Errors ของ Task service นะครับ
src/features/tasks/task.errors.ts
import { Data } from "effect"import { EffectHelpers } from "@/shared/effect"
export class TaskCreateError extends Data.TaggedError("Task/Create/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class TaskGetByIdError extends Data.TaggedError("Task/GetById/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class TaskGetAllError extends Data.TaggedError("Task/GetAll/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class TaskUpdateError extends Data.TaggedError("Task/Update/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class TaskDeleteError extends Data.TaggedError("Task/Delete/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
ตอนนี้เรามี Services แล้ว
ซึ่ง Business Logic ของเรา ก็จะอยู่ใน Services
ที่เหลือก็แค่เปิดทางเข้าให้ Business logic โดยเราจะใช้ oRPC ทำให้เหมือนเราเปิดทางเข้า ได้ 2 ทาง คือ 1. RPC, 2. REST API
หรือถ้าวันหนึ่งอยากเปลี่ยนทางเข้า ไม่อยากให้เข้าผ่าน REST API เราก็ปิดทางเข้าอย่างเดียว ไม่ต้องปิด Services หรืออยากให้เรียกใช้ผ่าน CLI ได้ เราก็แค่เพิ่มทางเข้าที่เป็น CLI ได้เลย
นั่นทำไมถึงแยก Business logic ไว้ใน Services
App Runtime
เนื่องจากเราใช้ Effect ในการทำ Dependencies injection เราก็เลยจะต้องมีใครสักคนที่คอย inject service ต่างๆให้เรา
ในที่นี้ก็จะเป็น App Runtime นี่แหละ
เราจะมาสร้าง App Runtime กัน
แล้วจะใส่ Services ต่างๆ ไว้ที่นี่แหละ
src/shared/runtime/index.ts
import { Layer, ManagedRuntime, Option } from "effect"import { TaskFileService } from "@/features/task-files/taskFile.service"import { TaskService } from "@/features/tasks/task.service"import { RedisProvider } from "@/providers/redis/redis.provider"
const mainLive = Layer.mergeAll( TaskService.Default,)
export const AppRuntime = ManagedRuntime.make(mainLive)
oRPC
การที่เราจะเอา Services ออกไปให้ใช้งานผ่าน RPC และ REST API ก็จะทำผ่าน oRPC
เราจะมาเตรียม oRPC กันก่อน
oRPC Base
ใน oRPC ผมจะเพิ่ม การกำหนด ResponseHeader แบบ custom ได้ด้วย
ซึ่งจะต้องเพิ่ม Plugin ResponseHeadersPluginContext
เข้าไปใน oRPC Server ด้วย
และในทุกๆ oRPC route ก็จะต้องสามารถ custom response header ได้เหมือนกัน
แต่บาง route ก็อาจจะไม่ได้ใช้งานก็ไม่เป็นไร
เราจะต้องสร้าง orpcBase
object แบบนี้
src/shared/orpc/base.ts
import type { ResponseHeadersPluginContext } from "@orpc/server/plugins"import { os } from "@orpc/server"
interface ORPCContext extends ResponseHeadersPluginContext {}
export const orpcBase = os.$context<ORPCContext>()
oRPC Handlers
oRPC มี handlers 2 แบบครับ
- RPC Handler
- OpenAPI Handler
เราอยากให้ client เรียกด้วยวิธีไหนก็ให้ใช้ handlers แบบนั้นได้เลย
สามารถใช้ทั้งสองตัวพร้อมกันได้เลย
oRPC Plugins
เราสามารถเพิ่มความสามารถให้กับ Handlers ผ่าน Plugins ได้ด้วยครับ ซึ่ง oRPC ก็เตรียม Plugins มาให้เราบางส่วนแล้ว
เช่น CORS, CSRF
ก็เลยจะพาไปเตรียม Plugins ก่อนที่จะไปสร้าง Handlers ครับ
โดยจะเตรียม 4 plugins ครับ
cors
csrf
response-headers
openapi-reference
โดยจะสร้าง plugins ทั้ง 4 ตัวไว้ใน folder src/shared/orpc/plugins
แล้ว export ผ่าน src/shared/orpc/plugins/index.ts
อีกทีนึงครับ
src └── shared ├── orpc │ └── plugins │ ├── cors.ts │ ├── csrf.ts │ ├── index.ts │ ├── openapi-reference.ts │ └── response-headers.ts
CORS Plugin
src/shared/orpc/plugins/cors.ts
import { CORSPlugin } from "@orpc/server/plugins"import { getEnvs } from "@/shared/config"
export function cors() { const config = getEnvs() return new CORSPlugin({ credentials: true, origin: config.CORS_ORIGIN, })}
CSRF Plugin
src/shared/orpc/plugins/csrf.ts
import { SimpleCsrfProtectionHandlerPlugin } from "@orpc/server/plugins"/** * CSRF protection plugin that helps prevent Cross-Site Request Forgery attacks * by validating tokens in requests */export const csrfProtection = new SimpleCsrfProtectionHandlerPlugin()
Response Headers
src/shared/orpc/plugins/response-headers.ts
import { ResponseHeadersPlugin } from "@orpc/server/plugins"/** * Plugin that allows adding custom headers to the response * This enables modifying response headers for each request */export const allowToAddResponseHeaders = new ResponseHeadersPlugin()
OpenAPI Reference document
plugin นี้จะช่วยสร้าง OpenAPI document ให้เรา โดยใช้ Scalar นะครับ
src/shared/orpc/plugins/openapi-reference.ts
/* eslint-disable perfectionist/sort-objects */import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"import { EffectSchemaToJsonSchema } from "../jsonSchema"
export const openapiReference = new OpenAPIReferencePlugin({ docsPath: "/docs", specPath: "/openapi.json", schemaConverters: [ new EffectSchemaToJsonSchema(), ], specGenerateOptions: { info: { title: "Task management API", version: "1.0.0", }, security: [], servers: [ { description: `${Bun.env.NODE_ENV} server`, url: `${Bun.env.SELF_URL}/api`, }, ], },})
จากโค้ดด้านบน
docsPath
จะเป็น path ของ OpenAPI document คือเราจะต้องเปิด browser แล้วใส่ path/docs
ก็จะเปิด Scalar ขึ้นมาspecPath
ก็จะเป็น OpenAPI spec ที่อยู่ในรูปของ json ซึ่งเดี๋ยวdocsPath
ก็จะให้ scalar มาเรียกดู OpenAPI spec นี้ก่อนที่จะให้ Scalar render Document สวยๆให้เราschemaConverters
เนื่องจากว่า OpenAPI Spec จะใช้ JSON Schema เป็นหลักครับ ผมก็เขียน Custom JSON Schema converter ขึ้นมา ชื่อว่าEffectSchemaToJsonSchema
เพราะผมใช้ Effect Schema ซึ่ง oRPC ไม่ได้ support Effect Schema
แต่ Effect Schema สามารถแปลงไปเป็น Standard Schema ได้ และ Effect ยังมี function ที่แปลง Standard Schema ไปเป็น JSON Schema ได้ด้วย
ตัวนี้แหละ จะเป็นตัวแปลง Standard Schema แล้วแปลงเป็น JSON Schema แบบ auto
ทำให้ plugin สร้าง OpenAPI Spec ได้ถูกต้อง เดี๋ยวเราจะไปสร้างมันหลังจากนี้แหละ
┌───────────────┐ ┌──────────────────┐ ┌─────────────┐ │ Effect Schema ┼────►│ Standard Schema ┼────► JSON Schema │ └───────────────┘ └──────────────────┘ └─────────────┘
- ส่วนอื่นๆก็เป็น info ของ API Document ก็ใส่ไปตามชอบได้เลย
Standard Schema to JSON Schema converter
เราจะมาสร้างตัวแปลง Standard Schema ให้ไปเป็น JSON Schema
src/shared/orpc/jsonSchema.ts
import type { AnySchema } from "@orpc/contract"import type { ConditionalSchemaConverter, JSONSchema, SchemaConvertOptions } from "@orpc/openapi"import { JSONSchema as EffectJSONSchema } from "effect"
export class EffectSchemaToJsonSchema implements ConditionalSchemaConverter { condition(schema: AnySchema | undefined): boolean { return schema !== undefined && schema["~standard"].vendor === "effect" }
convert(schema: AnySchema | undefined, _options: SchemaConvertOptions): [required: boolean, jsonSchema: Exclude<JSONSchema, boolean>] { const jsonSchema = EffectJSONSchema.make(schema as any) as Exclude<JSONSchema, boolean> return [true, jsonSchema] }}
oRPC Handlers functions
มาสร้าง function ที่เอาไว้สร้าง oRPC Handlers กัน
ที่ต้องทำเป็น function เพราะว่า เรายังไม่มี orpc routes
ก็เลยจะเตรียม Plugins ต่างๆให้พร้อมไว้ตรงนี้เลย
เมื่อเรามี orpc routes เราก็ค่อยเรียกใช้ functions เหล่านี้อีกทีนึง ที่ Hono app ในภายหลัง
src/shared/orpc/handlers.ts
import type { Context, Router } from "@orpc/server"import { OpenAPIHandler } from "@orpc/openapi/fetch"import { RPCHandler } from "@orpc/server/fetch"import { allowToAddResponseHeaders, cors, csrfProtection } from "./plugins"import { openapiReference } from "./plugins/openapi-reference"
export function createRpcHandler<T extends Context>(routers: Router<any, T>) { return new RPCHandler(routers, { plugins: [ cors(), Bun.env.NODE_ENV === "production" ? csrfProtection : undefined, allowToAddResponseHeaders, ].filter(d => !!d), })}
export function createOpenApiHandler<T extends Context>(routers: Router<any, T>) { return new OpenAPIHandler(routers, { plugins: [ cors(), Bun.env.NODE_ENV === "production" ? csrfProtection : undefined, openapiReference, allowToAddResponseHeaders, ].filter(d => !!d), })}
จากโค้ดด้านบน ผมสร้าง handlers ทั้งแบบ RPC และแบบ OpenAPI เลย
จะต่างกันแค่ใน OpenAPI handler จะมี plugin openapiReference
เพิ่มมาอีกตัวนึงครับ
แล้วเราจะไปเรียกใช้ create handlers ทั้งสองตัวนี้ในภายหลังตอน setup Hono app อีกทีนึงนะครับ
files & folders in oRPC
ตอนนี้ในส่วนของ oRPC จะมีไฟล์แบบนี้ครับ
src └── shared ├── orpc │ ├── base.ts │ ├── handlers.ts │ ├── jsonSchema.ts │ └── plugins │ ├── cors.ts │ ├── csrf.ts │ ├── index.ts │ ├── openapi-reference.ts │ └── response-headers.ts
เดี๋ยวตอนหลังเราจะเพิ่ม
Task Routes
ต่อมา เราจะมาทำ
ซึ่ง route นี้ จะสามารถนำไปใช้เป็น rpc server ก็ได้ ใช้เป็น REST api ก็ได้ โดยจะมี OpenAPI docs พร้อมเลย
สำหรับ oRPC routes ของ Task ก็จะมี
- create route
- get by id route โดยจะใช้ path params ในการระบุ TaskId นะครับ
- get all tasks โดยจะใช้ query params ในการระบุ page และ itemsPerPage นะครับ เพื่อทำ pagination
- update route โดยจะใช้ path params ในการระบุ TaskId และ body ของ request จะเป็น TaskCreateSchema
- delete route โดยจะใช้ path params ในการระบุ TaskId
เราจะค่อยๆทำไปทีละ route นะครับ
เริ่มที่ create route ก่อน
Create route
src/features/tasks/task.route.ts
import { Duration, Effect, pipe } from "effect"import * as S from "effect/Schema"import { ExitHelpers } from "@/shared/effect"import { orpcBase } from "@/shared/orpc/base"import { cacheMiddleware } from "@/shared/orpc/middlewares/cache.middleware"import { AppRuntime } from "@/shared/runtime"import { TaskArraySchemaStd, TaskCreateSchema, TaskId, TaskSchemaStd } from "./task.schema"import { TaskService } from "./task.service"
const tags = ["Task"] as const
const createTaskRoute = orpcBase .route({ description: "create task", inputStructure: "detailed", method: "POST", path: "/", tags, }) .input( pipe( S.Struct({ body: TaskCreateSchema, }), S.standardSchemaV1, ), ) .output(TaskSchemaStd) .errors({ BAD_REQUEST: { data: pipe(S.Unknown, S.standardSchemaV1), }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, }) .handler(async ({ context: _ctx, errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.create(input.body) return data }).pipe( Effect.catchAll(error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
จากโค้ดด้านบนจะเห็นว่าเยอะพอสมควร เดี๋ยวผมจะค่อยๆ อธิบายไปทีละส่วนนะครับ
เริ่มต้นที่สร้าง route ด้วยการเอา orpcBase
ที่เราได้ทำไว้ก่อนหน้านี้มาใช้
จากนั้นก็ใช้ method chaining ไปเรื่อยๆครับ
route
เริ่มที่ .route()
ก่อนเลย
const createTaskRoute = orpcBase .route({ description: "create task", inputStructure: "detailed", method: "POST", path: "/", tags, }) .input(
จะเป็น config ที่ใช้กับ OpenAPI ครับ
ถ้าใครใช้แค่ RPC อย่างเดียว ไม่ต้องใส่ .route()
ก็ได้ ข้ามไปได้เลย
description จะเป็นคำอธิบาย api เส้นนี้เวลาเปิดดูที่ Scalar ก็จะเห็นคำอธิบายตามที่ใส่ไวinputStructure :detailed
อันนี้สำคัญเลยครับ มันเป็นตัวกำหนด method.input()
ในส่วนถัดไปครับ เป็นการบอกว่า เดี๋ยวเราจะกำหนดหน้าตาของข้อมูล (Schema) ที่ต้องอยู่ใน body, path params, query, headers แต่ละตัวต้องมีหน้าตาแบบไหน แบบละเอียดเป็นรายตัวเลยครับ
ซึ่งตรงนี้สามารถเปลี่ยนเป็นcompact
ได้ด้วยนะ ซึ่งปกติค่า default จะเป็นcompact
ครับ
โดยcompact
คือ oRPC จะดูให้เองว่า Schema ที่เรากำหนดควรจะไปอยู่ตรงไหน เช่นถ้า Method=POST ก็จะทำให้ Schema ที่ใส่ต้องไปอยู่ในส่วนของ Body ครับ
ตรงนี้บางครั้งผมต้องการใช้หลายอย่างร่วมกัน ก็เลยใช้detailed
ไปเลยดีกว่าmethod :POST
ก็ใช้ method POST นี่แหละpath :"/"
อันนี้ก็คือ REST API path ซึ่งผมให้เป็น ”/” ตรงๆเลย เดี๋ยวเราจะไปกำหนด path อีกทีนึงตอนเอา routes ต่างๆไปรวมกันtags อันนี้ก็แค่เอาไว้แยกกลุ่มของ routes ต่างๆ เวลาดูที่ Scalar docs ครับ ซึ่งผมทำเป็นตัวแปรแยกออกไปเลย จะได้กำหนดให้ routes หลายๆตัวได้ง่ายๆครับ
เดี๋ยว routes อื่นๆก็จะใช้ tags เดียวกันนี้อยู่แล้วครับ
input
ถัดมา ส่วนของ .input()
ครับ
.input( pipe( S.Struct({ body: TaskCreateSchema, }), S.standardSchemaV1, ), )
ใน .input()
จะต้อง ใส่ Schema ครับ โดย Schema ที่ใส่ต้องเป็น Standard Schema ด้วยครับ
ซึ่ง Effect ได้เตรียม function สำหรับแปลง Effect Schema ให้ไปเป็น Standard Schema เรียบร้อยแล้วครับ
ฉนั้นผมก็เลยใช้ pipe()
กับ S.standardSchemaV1()
มาช่วย โดยสร้าง Effect Schema ก่อน แล้วก็ให้ เรียก S.standardSchemaV1()
เพื่อแปลง Effect Schema ให้ไปเป็น Standard schema อีกทีนึงครับ
ขอลงลึกตรงนี้นิดนึงนะครับ
เราต้องเริ่มต้อนด้วยการใช้ S.Struct()
เสมอครับ
จากนั้นภายใน Struct จะต้องใส่ key เป็น body
| params
| query
| headers
ครับ
จากนั้น value ของแต่ละ key ก็ต้องเป็น S.Struct()
อีกทีนึงเช่นกันครับ ตามแต่ว่าเราต้องการให้ client ส่งอะไรมาในส่วนใด
จากตัวอย่างด้านบนนี้ การ create Task ผมต้องการให้ client ส่งข้อมูล Task มาทาง body อย่างเดียว ก็เลยมีแค่ body ครับ
เดี๋ยวตอนทำ routes อื่นๆ ก็จะได้เห็น การใช้งาน params
กับ query
เพิ่มเติมครับ
สุดท้ายก็แปลงทั้งหมดให้ไปเป็น Standard Schema ผ่าน pipe()
และ S.standardSchemaV1()
output
ต่อมาเป็นส่วนของ .output()
.output(TaskSchemaStd)
อันนี้ไม่ได้มีอะไรมากมาย เราแค่กำหนด Schema ไปว่า เมื่อ route นี้ทำงานเสร็จ จะ return อะไรกลับไปให้ client บ้าง
ตรงนี้ได้ทำ TaskSchema
ให้เป็น Standard Schema ไว้แล้ว ก็เลยเอามาใช้ตรงๆได้เลย
Errors
ถัดมาเป็นส่วนของ .errors()
.errors({ BAD_REQUEST: { data: pipe(S.Unknown, S.standardSchemaV1), }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, })
เราจะกำหนด error ไว้ล่วงหน้าได้เลย
เราก็แค่ดูว่า route นี้จะมี error อะไรบ้าง
ซึ่ง Errors ตรงนี้จะไปอยู่ใน OpenAPI Spec ด้วย
และใน RPC Client ก็จะเห็นด้วยว่าอาจจะมี Error อะไรบ้าง data เป็นแบบไหน
สามารถใส่ message
เพิ่มเติมได้ด้วยนะ แต่ในตัวอย่างด้านบนผมใส่แค่ data
อย่างเดียว และมี type เป็น S.Unknown
เนื่องจากว่าต้องการให้เก็บ Error message ที่เป็น original ซึ่งก็ไม่รู้ว่ามันมีหน้าตาแบบไหน
handler
สุดท้ายจะเป็นส่วนของ .handler()
.handler(async ({ context: _ctx, errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.create(input.body) return data }).pipe( Effect.catchAll(error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
จากตัวอย่างด้านบน เราส่ง callback function ไปให้ .handler()
ซึ่งใน parameters ของ callback ก็จะมี context
, errors
, input
ผมจะเรียกใช้ TaskService ที่ได้ทำไปก่อนหน้านี้แล้ว ใน Effect.gen()
นะครับ
และใน Effect.gen()
นี้ผมจะใช้ yield*
ในการเรียกใช้ TaskService
ซึ่ง TaskService ก็จะมี function create()
และ create()
มัน return Effect ก็เลยต้องใช้ yield*
ด้านหน้าด้วย
ถ้า create ได้สำเร็จก็จะได้ data
กลับมา
แต่ถ้าไม่สำเร็จ ก็จะมี Effect.catchAll
อยู่ด้านล่าง ถ้ามี error อะไรเกิดขึ้น ก็ให้สร้างเป็น Error ตัวใหม่ผ่าน Effect.fail()
ด้านใน Effect.fail()
ก็จะใส่ errors ที่เราได้ defined ไว้ในส่วนของ .errors()
ซึ่งมันจะมาโผล่ใน callback parameters ด้วย ก็เรียกใช้ได้เลย ตามโค้ดตัวอย่าง
สุดท้ายก็เอา AppRuntime
มารัน โดยจะใช้ .runPromiseExit()
นะครับ
แล้วเราจะได้ Exit
กลับมา ซึ่งถ้าเกิดมี Error โปรแกรมเราจะไม่ crash มันก็จะไปอยู่ใน Exit แทน
แล้วใช้ ExitHelpers.getDataOrThrowRawError()
ใน function นี้ ถ้ายังจำได้เราได้สร้างกันไปก่อนหน้าแล้ว
การทำงานของมันก็คือ เอา Exit มาดู ถ้ามี data ก็จะ return data ปกติ
แต่ถ้ามี Error มันจะแกะ Raw Error ออกมา แล้ว throw Error ออกมาเลย
ซึ่งใน oRPC ถ้ามี Error เราจะต้อง Throw แล้วเดี๋ยว oRPC จะจัดการ response กลับไปหา user ตาม error นั้นๆให้เอง
export task routes
สุดท้ายจะ export createTaskRoute
ไปแบบนี้ครับ
src/features/tasks/task.route.ts
47 collapsed lines
import { Duration, Effect, pipe } from "effect"import * as S from "effect/Schema"import { ExitHelpers } from "@/shared/effect"import { orpcBase } from "@/shared/orpc/base"import { cacheMiddleware } from "@/shared/orpc/middlewares/cache.middleware"import { AppRuntime } from "@/shared/runtime"import { TaskArraySchemaStd, TaskCreateSchema, TaskId, TaskSchemaStd } from "./task.schema"import { TaskService } from "./task.service"
const tags = ["Task"] as const
const createTaskRoute = orpcBase .route({ description: "create task", inputStructure: "detailed", method: "POST", path: "/", tags, }) .input( pipe( S.Struct({ body: TaskCreateSchema, }), S.standardSchemaV1, ), ) .output(TaskSchemaStd) .errors({ BAD_REQUEST: { data: pipe(S.Unknown, S.standardSchemaV1), }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, }) .handler(async ({ context: _ctx, errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.create(input.body) return data }).pipe( Effect.catchAll(error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
export const taskRoutes = { createTaskRoute,}
export all routes
มาที่ ไฟล์ src/features/index.ts
เราจะ export routes ทั้งหมดที่นี่ รวมถึง routes สำหรับ features อื่นๆในอนาคตด้วย เอามารวมตรงนี้ให้หมดเลย
แล้วเราจะกำหนด path ตรงนี้แหละ ผ่าน prefix()
src/features/index.ts
import { orpcBase } from "@/shared/orpc/base"import { taskRoutes } from "./tasks/task.route"
export const routes = { tasks: orpcBase.prefix("/tasks").router(taskRoutes),}
จาก code ด้านบน ก็จะเอา taskRoutes มาใส่ใน orpcBase.prefix("/tasks")
แบบ code ด้านบน
ตอนนี้พร้อมที่จะ integrate เข้ากับ Hono app แล้ว
integrate with Hono app
เราจะเอา routes ที่ได้ทำไว้ มาใส่ Hono app แบบนี้
มาที่ไฟล์ src/app.ts
src/app.ts
import { Hono } from "hono"import { createOpenApiHandler, createRpcHandler } from "@/shared/orpc/handlers"import { routes } from "./features"
const rpcHandlers = createRpcHandler(routes)const openApiHandlers = createOpenApiHandler(routes)
const app = new Hono()app.use("/rpc/*", async (c, next) => { const { matched, response } = await rpcHandlers.handle(c.req.raw, { context: {}, // Provide initial context if needed prefix: "/rpc", })
if (matched) { return c.newResponse(response.body, response) } await next()}) .use("/api/*", async (C, next) => { const { matched, response } = await openApiHandlers.handle(C.req.raw, { context: {}, // Provide initial context if needed prefix: "/api", }) if (matched) { return C.newResponse(response.body, response) } await next() })
const serverOptions: Bun.ServeFunctionOptions<unknown, 0> = { fetch: app.fetch, port: Bun.env.PORT || 3333,}
export default serverOptions
จาก code ด้านบน
เราสร้าง handlers มา 2 ตัวเลยคือ RPC กับ OpenAPI ครับ
โดยจะใช้ createRpcHandler
กับ createOpenApiHandler
แล้วเอามารวมเข้ากับ Hono app ผ่าน app.use()
ครับ
โดย rpc ก็จะให้ใช path /rpc/*
ส่วน OpenAPI ก็จะเป็น path /api/*
ครับ
ขอเพิ่มเติมส่วนของ OpenAPI อีกสักนิดครับ
ถ้าจำได้ตอนเราสร้าง openapi-reference plugin เราได้ใส่ path สำหรับ Scalar เป็น /docs
และ path สำหรับ OpenAPI spec เป็น /openapi.json
เมื่อเอามารวมกับ Hono app แล้ว
เราอยากเปิด Scalar ก็ต้องใช้ url แบบนี้ครับ http://localhost:3333/api/docs
ถ้าอยากเปิด OpenAPI JSON spec ก็ต้องใช้ url แบบนี้ครับ http://localhost:3333/api/openapi.json
คือต้องมี /api
ด้านหน้านั่นแหละครับ
Start Hono app server
setup .env
เพิ่ม ENV ต่างๆก่อน
DATABASE_URL="postgresql://postgres:mysecretpassword@localhost:5432/tasks?schema=public"SELF_URL="http://localhost:3333"PORT="3333"NODE_ENV="development"CORS_ORIGIN="http://localhost:3333"REDIS_URL="redis://localhost:6379"MINIO_ACCESS_KEY=uploads-accessMINIO_ENDPOINT=localhostMINIO_PORT=9000MINIO_SECRET_KEY=uploads-secret-123
dev script
เรามาเพิ่ม dev
script กันก่อน เพื่อให้ง่ายต่อการรัน server ด้วย
package.json
"scripts": {13 collapsed lines
"prepare": "bun .husky/install.mjs", "dotenvx": "dotenvx", "env:dev:dc": "dotenvx decrypt", "env:dev:ec": "dotenvx encrypt", "env:uat:ec": "dotenvx -f .env.uat encrypt", "env:uat:dc": "dotenvx -f .env.uat decrypt", "env:prod:ec": "dotenvx -f .env.prod encrypt", "env:prod:dc": "dotenvx -f .env.prod decrypt", "env:all:dc": "bun env:dev:dc && bun env:uat:dc && bun env:prod:dc", "env:all:ec": "bun env:dev:ec && bun env:uat:ec && bun env:prod:ec", "dotenvx:example": "bun env:dev:dc && bun run dev", "dotenvx:start": "dotenvx run -- bun start", "commit": "lint-staged && cz", "dev": "dotenvx run -- bun --watch src/app.ts", "build": "bun build.ts" },
ลอง start server ด้วยคำสั่ง
bun dev
ลองเปิดไปที่ /api/docs
น่าจะเจอ Scalar แบบนี้

more Task routes
เราจะมาทำ task routes เพิ่มกันครับ ยังเหลือ Get by id, Get all, Update, Delete ครับ
route Get Task by id
src/features/tasks/task.route.ts
const getTaskByIdRoute = orpcBase .route({ description: "get example template", inputStructure: "detailed", method: "GET", path: "/:taskId", successStatus: 200, tags, }) .input( pipe( S.Struct({ params: S.Struct({ taskId: S.NumberFromString.pipe( S.transform(TaskId, { decode: id => TaskId.make(id), encode: id => id, }), ), }), }), S.standardSchemaV1, ), ) .output(TaskSchemaStd) .errors({ BAD_REQUEST: { data: pipe(S.Unknown, S.standardSchemaV1), }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, NOT_FOUND: { data: pipe(S.Unknown, S.standardSchemaV1), }, }) .handler(async ({ context: _ctx, errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.getById(input.params.taskId) return data }).pipe( Effect.catchTags({ "NoSuchElementException": error => Effect.fail(errors.NOT_FOUND({ data: error })), "ParseError": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), "Task/GetById/Error": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), }), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
จาก code ด้านบน ผมจะอธิบายเฉพาะส่วนที่เพิ่มเติมมานะครับ หลักๆ ก็จะคล้ายๆเดิมแทบทั้งหมด
route
ส่วนของ .route()
จะใช้ path params ด้วย
จะเห็นว่า path: "/:taskId"
คือใช้ :
แล้วตามด้วย params name ได้เลย
สามารถใช้ {}
ก็ได้นะ เช่น path: "/{taskId}"
input
มาในส่วนของ .input()
.input( pipe( S.Struct({ params: S.Struct({ taskId: S.NumberFromString.pipe( S.transform(TaskId, { decode: id => TaskId.make(id), encode: id => id, }), ), }), }), S.standardSchemaV1, ), )
ก็จะใช้ S.Struct()
เหมือนเดิม
แล้วก็ใส่ params: S.Struct({taskId: S.NumberFromString})
อีกทีนึงครับ
ชื่อต้องตรงกันด้วยนะ เช่นใน path ใช้ /:taskId
ตรงนี้ params name ก็จะต้องเป็น taskId
ด้วย
output & errors
ส่วนนี้ไม่ได้มีอะไรเป็นพิเศษก็จะขอข้ามไปเลยละกัน
handler
มาในส่วนของ .handler()
.handler(async ({ context: _ctx, errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.getById(input.params.taskId) return data }).pipe( Effect.catchTags({ "NoSuchElementException": error => Effect.fail(errors.NOT_FOUND({ data: error })), "ParseError": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), "Task/GetById/Error": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), }), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
ในส่วนของ handler ก็จะคล้ายๆเดิมเลย
แต่ก็อยากจะอธิบายเพิ่มในส่วนของ Effect.catchTags()
นะครับ
ตรงนี้จะเป็นการเปลี่ยน Error ที่เกิดขึ้นจาก TaskService ให้เป็น Error ของ oRPC
แต่ละตัวก็จะ map ให้เข้ากับ Http Error code ครับ
โดย
NoSuchElementException
คือหา taskId ไม่เจอ ก็ต้องใช้ NOT_FOUND (404)ParseError
กับTask/GetById/Error
คือ error ที่เกิดขึ้นใน Service ก็ต้องใช้ INTERNAL_SERVER_ERROR (500)
ที่ต้อง map Error ที่ route ก็เพราะว่า มันไม่เกี่ยวกับ Business logic ครับ
ถ้าเราไปใช้ cli แทน oRPC ก็คงจะไม่จำเป็นต้องใช้ Http Error code
ก็เลยไม่อยากให้มันปนกัน
route update task
ก็ยังทำอยู่ในไฟล์เดิมนะแหละ
src/features/tasks/task.route.ts
const updateTaskRoute = orpcBase .route({ description: "update task", inputStructure: "detailed", method: "PUT", path: "/:taskId", tags, }) .input( S.Struct({ body: TaskCreateSchema, params: S.Struct({ taskId: S.NumberFromString.pipe( S.transform(TaskId, { decode: id => TaskId.make(id), encode: id => id, }), ), }), }).pipe( S.standardSchemaV1, ), ) .output(TaskSchemaStd) .errors({ BAD_REQUEST: { data: pipe(S.Unknown, S.standardSchemaV1), }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, NOT_FOUND: { data: S.Unknown.pipe(S.standardSchemaV1), }, }) .handler(async ({ errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.update(input.params.taskId, input.body) return data }).pipe( Effect.catchAll(error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
ส่วนใหญ่ก็จะเหมือนเดิมเลย
แต่ก็จะมีส่วนของ .input()
ที่เปลี่ยนไปนิดหน่อย
.input( S.Struct({ body: TaskCreateSchema, params: S.Struct({ taskId: S.NumberFromString.pipe( S.transform(TaskId, { decode: id => TaskId.make(id), encode: id => id, }), ), }), }).pipe( S.standardSchemaV1, ), )
คือมีทั้ง body
และ params
ที่ client ต้องส่งมา
ซึ่งก็เป็นตัวอย่างนึงที่ต้องใช้ inputStructure: "detailed"
นะครับ
route delete task
ส่วนของ delete ก็จะคล้ายๆกันแหละ ผมขอไม่อธิบาย ใครที่ทำตามไปด้วยก็น่าจะพอเข้าใจกันแล้วละ
src/features/tasks/task.route.ts
const deleteTaskRoute = orpcBase .route({ description: "delete task", inputStructure: "detailed", method: "DELETE", path: "/:taskId", successStatus: 200, tags, }) .input( S.Struct({ params: S.Struct({ taskId: S.NumberFromString.pipe( S.transform(TaskId, { decode: id => TaskId.make(id), encode: id => id, }), ), }), }).pipe(S.standardSchemaV1), ) .output(TaskSchemaStd) .errors( { INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, NOT_FOUND: { data: S.Unknown.pipe(S.standardSchemaV1), }, }, ) .handler(async ({ errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.deleteById(input.params.taskId) return data }).pipe( Effect.catchTag("NoSuchElementException", error => Effect.fail(errors.NOT_FOUND({ data: error }))), Effect.catchTag("ParseError", error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), Effect.catchTag("Task/Delete/Error", error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
route get all tasks
จริงส่วนนี้ก็จะคล้ายๆเดิมเลย
แต่ก่อนที่จะไปทำ route สำหรับ get all tasks
ผมอยากจะใส่ caching เพิ่มเติมเข้าไปสำหรับ route get all tasks อันนี้ครับ
โดยจะใช้
ผมจะใช้ Redis เก็บ Cache data นะครับ
ฉนั้นตรงนี้จะขอพักไว้ก่อน
เราจะต้องไปสร้าง Redis provider และ Cache Middleware กันก่อน
Redis Provider
ซึ่งวิธีการสร้าง Redis provider ก็จะเหมือนกับ Prisma provider เลย
แต่จะมี function ที่ผมเขียนเพิ่ม เพื่อให้เรียกใช้ได้ง่ายๆครับ
src/providers/redis/redis.provider.ts
import { Duration, Effect, flow, Match, Option } from "effect"import { createClient } from "redis"import { getEnvs } from "@/shared/config"import { RedisConnectError, RedisDelError, RedisDisconnectError, RedisGetError, RedisSetError } from "./redis.errors"
export class RedisProvider extends Effect.Service<RedisProvider>()("Redis/Provider", { dependencies: [], effect: Effect.fn(function* (defaultTtl: Option.Option<Duration.Duration>) { const config = getEnvs()
const client = createClient({ url: config.REDIS_URL, })
const connect = () => Effect.tryPromise({ catch: RedisConnectError.new(), try: () => client.connect(), })
const disconnect = () => Effect.try({ catch: RedisDisconnectError.new(), try: () => client.destroy(), })
const set = <Data>(key: string, value: Data, ttl: Option.Option<Duration.Duration> = Option.none()) => Effect.tryPromise({ catch: (e) => { return RedisSetError.new()(e) }, try: async () => { const serialized = JSON.stringify(value) const ttlDuration = Match.value({ defaultTtl, ttl }).pipe( Match.when({ ttl: { _tag: "Some" } }, ({ ttl }) => ttl.value), Match.when({ defaultTtl: { _tag: "Some" } }, ({ defaultTtl }) => defaultTtl.value), Match.orElse(() => Duration.seconds(0)), ) const ttlSecs = Duration.toSeconds(ttlDuration) if (ttlSecs === 0) { return client.set(key, serialized).then(() => "OK") } return client.setEx(key, ttlSecs, serialized) }, }).pipe( Effect.tapError(Effect.logError), )
const get = <Data>(key: string) => Effect.tryPromise({ catch: RedisGetError.new(), try: () => client.get(key), }).pipe( Effect.andThen(Effect.fromNullable), Effect.map(str => JSON.parse(str) as Data), )
const getOrSet = <Data, Err>(key: string, fn: () => Effect.Effect<Data, Err>, ttl: Option.Option<Duration.Duration> = Option.none()) => get<Data>(key).pipe( Effect.catchAll(() => fn().pipe( Effect.tap(data => set(key, data, ttl)), )), )
const del = (key: string) => Effect.tryPromise({ catch: RedisDelError.new(), try: () => client.del(key), })
const has = flow(get, Effect.map(() => true), Effect.orElseSucceed(() => false))
yield* connect()
return { connect, del, disconnect, get, getOrSet, has, redisClient: client, set, } }),
}) { }
อธิบายโค้ดด้านบนแบบนี้
-
effect: Effect.fn(function* (defaultTtl: Option.Option<Duration.Duration>) {
เอาบรรทัดนี้ก่อนเลย
ตรงนี้จะเห็นว่าผมไม่ได้ใช้Effect.gen()
แต่ใช้Effect.fn()
แทน จริงๆมันก็เหมือนกัน แต่Effect.fn()
จะรับ argument ได้ด้วย ในที่นี้ผมก็ให้รับเป็น Duration ครับ คือจะให้ cache นานแค่ไหนก็ใส่มา
โดยให้ใส่เป็น Option ครับ ถ้าไม่ต้องการให้ cache หมดอายุก็ใส่Option.none()
ไปเลยครับ -
ส่วน set function
set: <Data>(key: string, value: Data, ttl?: Option.Option<Duration.Duration>) => Effect.Effect<string, RedisSetError, never>
ผมให้ value มี type เป็น Generic ส่วน key จะให้เป็น string เสมอ
และถ้าไม่ได้ระบุ ttl มา ก็จะให้ไปเอา defaultTtl มาใช้ ถ้ายังไม่มีอีก ก็ไม่ต้องหมดอายุอยู่กันไปยาวๆเลย -
get function ก็ไม่ได้มีอะไรพิเศษครับ
-
getOrSet()
อันนี้อยากจะอธิบายสักหน่อยครับ
functiongetOrSet()
อันนี้จะรับ parameters 3 ตัวครับ-
key: string
ก็เป็น key ไง
-
fn: () => Effect.Effect<Data, Err>
จะรับ function ที่ return Effect ครับ โดยทั่วไปก็จะเป็น function ที่เอาไว้ get data เป็น function ที่อยู่ใน Services อะแหละ
-
ttl?: Option.Option<Duration.Duration>
ก็จะเป็น ttl ของ cache นะครับ
-
การทำงานของ function นี้คือ จะไปดู cache ใน Redis โดยใช้ key ที่ได้รับมาก่อน ถ้าไม่ได้ของใน Redis ก็จะไปเรียก fn()
ให้ทำงาน แล้วเอา data ที่ได้จาก fn()
ไปใส่ Redis ตาม key ที่ระบุ แล้ว return data ที่ได้
ทำให้เราใช้ cache แบบ
- ส่วน function อื่นๆก็ตรงตัว ขอข้ามการอธิบายไปเลยนะครับ ถ้าใครยังสงสัยก็ DM มาถามกันได้ที่ page หรือโทรมาก็ยินดีครับ
- และสุดท้ายยังมี Errors ที่เราเรียกใช้งาน แต่ยังไม่ได้สร้าง ก็จะพาไปสร้างในส่วนต่อไปนี่ละครับ
Redis Errors
src/providers/redis/redis.errors.ts
import { Data } from "effect"import { EffectHelpers } from "@/shared/effect"
export class RedisSetError extends Data.TaggedError("Redis/Set/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class RedisGetError extends Data.TaggedError("Redis/Get/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class RedisDelError extends Data.TaggedError("Redis/Del/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class RedisConnectError extends Data.TaggedError("Redis/Connect/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}export class RedisDisconnectError extends Data.TaggedError("Redis/Disconnect/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
ตรงนี้ก็สร้าง Error class สำหรับ Redis ไม่ได้มีอะไรพิเศษเลยครับ
ตอนนี้ Redis provider folder ก็จะมีไฟล์แบบนี้ครับ
src ├── providers │ ├── prisma │ └── redis │ ├── redis.errors.ts │ └── redis.provider.ts
oRPC Cache Middleware
ต่อมาเราจะสร้าง cache middleware กันครับ
src/shared/orpc/middlewares/cache.middleware.ts
import type { Duration } from "effect"import { Effect, Option } from "effect"import { RedisProvider } from "@/providers/redis/redis.provider"import { ExitHelpers } from "@/shared/effect"import { AppRuntime } from "@/shared/runtime"import { orpcBase } from "../base"
export function cacheMiddleware(cacheTtl: Duration.Duration) { return orpcBase.middleware(async ({ next, path }, input: unknown) => { const cacheKey = `${path}-${JSON.stringify(input)}` const cacheData = await RedisProvider.pipe( Effect.andThen(redis => redis.getOrSet( cacheKey, () => Effect.tryPromise(async () => next()), Option.some(cacheTtl), )), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError)
return cacheData })}
อธิบายโค้ดด้านบนหน่อย
ผมสร้าง function cacheMiddleware()
ที่รับ parameters 1 ตัว คือเวลาหมดอายุของ cache ครับ
แล้วให้ return orpcBase.middleware()
ครับ
ซึ่งใน .middleware()
ก็ต้องส่ง callback function เข้าไป
callback function ก็จะมี parameters เป็น { next, path }, input: unknown
ครับ
input
จะมี type เป็นอะไรก็ได้ ผมไม่ได้สนใจpath
ก็จะเป็น path ของ RPC กับ OpenAPI path ครับ มันเป็น Array ครับnext
ก็จะเป็น async function ที่บอกให้ middleware ไปเรียก handler ของ route ให้ทำงานต่อไป พอ handler ทำงานเสร็จ ตัวnext()
ก็จะ return data ของ handler กลับมาให้ middleware ตัวนี้ทำงานต่อไป
ผมจะเอา path
กับ input
มารวมกันเป็น string ก้อนเดียว แล้วทำเป็น cache key เลย
โดยถ้า client เรียกมา path เดิม ด้วย input เดิม ควรจะต้องไปดู data ที่ cache ก่อนเลย ถ้าเจอ data ใน cache ก็จะให้ middleware return data กลับไปเลย ไม่ต้องไปถึง handler แล้ว
แต่ถ้าไมเจอก็ค่อยให้ handler ทำงานแล้ว return data กลับมา แล้วค่อย cache
ตรงนี้ getOrSet()
ที่ทำไว้จะเป็นประโยชน์มากๆครับ เพราะมันทำให้เราไม่ต้องคิดเยอะ
Get all Task route
กลับมาที่ route get all tasks
src/features/tasks/task.route.ts
const getTasksRoute = orpcBase .route({ description: "get tasks", inputStructure: "detailed", method: "GET", path: "/", successStatus: 200, tags, }) .input( pipe( S.Struct({ query: S.Struct({ itemsPerPage: S.NumberFromString.pipe(S.optionalWith({ default: () => 10 })), page: S.NumberFromString.pipe(S.optionalWith({ default: () => 1 })), }), }), S.standardSchemaV1, ), ) .output(TaskArraySchemaStd) .errors({ INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, }) .use(cacheMiddleware(Duration.minutes(1))) .handler(async ({ context, errors, input }) => { const exit = await Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.getAll(input.query) context.resHeaders?.set("x-data-page", input.query.page.toString()) return data }).pipe( Effect.catchTags({ "ParseError": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), "Task/GetAll/Error": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), }), AppRuntime.runPromiseExit, ) return ExitHelpers.getDataOrThrowRawError(exit) })
อธิบายโค้ดด้านบนแบบนี้
input
ในส่วนของ .input()
.input( pipe( S.Struct({ query: S.Struct({ itemsPerPage: S.NumberFromString.pipe(S.optionalWith({ default: () => 10 })), page: S.NumberFromString.pipe(S.optionalWith({ default: () => 1 })), }), }), S.standardSchemaV1, ), )
จะใช้ query parameter itemsPerPage
และ page
ในการทำ pagination ครับ
ซึ่ง client ก็จะต้องส่งค่าผ่าน Search params บน url เข้ามาครับ
โดยถ้าไม่ระบุค่ามาเราก็มีค่า default ไว้ให้แล้ว คือ page=1, itemsPerPage=10
use
.use()
เราจะเอา middleware มาใช้ใน use()
นี่แหละ
.use(cacheMiddleware(Duration.minutes(1)))
ใน code ผมให้ cache มีอายุ 1 นาทีครับ
other method
ส่วนอื่นๆผมขอไม่อธิบาย มันก็เหมือนๆกับ routes อื่นๆครับ
export all task’s routes
สุดท้ายเราก็จะ export routes ทั้งหมดแบบนี้ครับ
src/features/tasks/task.route.ts
235 collapsed lines
import { Duration, Effect, pipe } from "effect"import * as S from "effect/Schema"import { ExitHelpers } from "@/shared/effect"import { orpcBase } from "@/shared/orpc/base"import { cacheMiddleware } from "@/shared/orpc/middlewares/cache.middleware"import { AppRuntime } from "@/shared/runtime"import { TaskArraySchemaStd, TaskCreateSchema, TaskId, TaskSchemaStd } from "./task.schema"import { TaskService } from "./task.service"
const tags = ["Task"] as const
const getTasksRoute = orpcBase .route({ description: "get all tasks", inputStructure: "detailed", method: "GET", path: "/", successStatus: 200, tags, }) .input( pipe( S.Struct({ // params: S.Struct({}), // body: S.Struct({}), // headers: S.Struct({}), query: S.Struct({ itemsPerPage: S.NumberFromString.pipe(S.optionalWith({ default: () => 10 })), page: S.NumberFromString.pipe(S.optionalWith({ default: () => 1 })), }), }), S.standardSchemaV1, ), ) .output(TaskArraySchemaStd) .errors({ INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, }) .use(cacheMiddleware(Duration.minutes(1))) .handler(async ({ context, errors, input }) => { const exit = await Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.getAll(input.query) context.resHeaders?.set("x-data-page", input.query.page.toString()) return data }).pipe( Effect.catchTags({ "ParseError": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), "Task/GetAll/Error": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), }), AppRuntime.runPromiseExit, ) return ExitHelpers.getDataOrThrowRawError(exit) })
const getTaskByIdRoute = orpcBase .route({ description: "get example template", inputStructure: "detailed", method: "GET", path: "/:taskId", successStatus: 200, tags, }) .input( pipe( S.Struct({ params: S.Struct({ taskId: S.NumberFromString.pipe( S.transform(TaskId, { decode: id => TaskId.make(id), encode: id => id, }), ), }), }), S.standardSchemaV1, ), ) .output(TaskSchemaStd) .errors({ BAD_REQUEST: { data: pipe(S.Unknown, S.standardSchemaV1), }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, NOT_FOUND: { data: pipe(S.Unknown, S.standardSchemaV1), }, }) .handler(async ({ context: _ctx, errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.getById(input.params.taskId) return data }).pipe( Effect.catchTags({ "NoSuchElementException": error => Effect.fail(errors.NOT_FOUND({ data: error })), "ParseError": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), "Task/GetById/Error": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), }), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
const createTaskRoute = orpcBase .route({ description: "create task", inputStructure: "detailed", method: "POST", path: "/", tags, }) .input( pipe( S.Struct({ body: TaskCreateSchema, }), S.standardSchemaV1, ), ) .output(TaskSchemaStd) .errors({ BAD_REQUEST: { data: pipe(S.Unknown, S.standardSchemaV1), }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, }) .handler(async ({ context: _ctx, errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.create(input.body) return data }).pipe( Effect.catchAll(error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
const updateTaskRoute = orpcBase .route({ description: "update task", inputStructure: "detailed", method: "PUT", path: "/:taskId", tags, }) .input( S.Struct({ body: TaskCreateSchema, params: S.Struct({ taskId: S.NumberFromString.pipe( S.transform(TaskId, { decode: id => TaskId.make(id), encode: id => id, }), ), }), }).pipe( S.standardSchemaV1, ), ) .output(TaskSchemaStd) .errors({ BAD_REQUEST: { data: pipe(S.Unknown, S.standardSchemaV1), }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, NOT_FOUND: { data: S.Unknown.pipe(S.standardSchemaV1), }, }) .handler(async ({ errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.update(input.params.taskId, input.body) return data }).pipe( Effect.catchAll(error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
const deleteTaskRoute = orpcBase .route({ description: "delete task", inputStructure: "detailed", method: "DELETE", path: "/:taskId", successStatus: 200, tags, }) .input( S.Struct({ params: S.Struct({ taskId: S.NumberFromString.pipe( S.transform(TaskId, { decode: id => TaskId.make(id), encode: id => id, }), ), }), }).pipe(S.standardSchemaV1), ) .output(TaskSchemaStd) .errors( { INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, NOT_FOUND: { data: S.Unknown.pipe(S.standardSchemaV1), }, }, ) .handler(async ({ errors, input }) => { return Effect.gen(function* () { const svc = yield* TaskService const data = yield* svc.deleteById(input.params.taskId) return data }).pipe( Effect.catchTag("NoSuchElementException", error => Effect.fail(errors.NOT_FOUND({ data: error }))), Effect.catchTag("ParseError", error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), Effect.catchTag("Task/Delete/Error", error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error }))), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) })
export const taskRoutes = { createTaskRoute, deleteTaskRoute, getTaskByIdRoute, getTasksRoute, updateTaskRoute,}
ลองเปิด scalar docs ดู
จะได้แบบนี้

MinIO Provider
ผมมีตัวอย่าง สำหรับ MinIO provider ด้วยครับ
oRPC สามารถรับ data ที่เป็น File หรือ Blob ได้ก็จริง แต่ผมคิดว่าใช้ MinIO เป็นที่เก็บไฟล์ภาพ เอกสาร ก็ยังดูเป็นวิธีที่ดีกว่าครับ
เนื่องจากถ้าไฟล์มีขนาดใหญ่ๆ oRPC จะไม่สามารถรับได้
พอไฟล์ใหญ่ๆ user ก็จะต้อง resume upload ได้ด้วย ตรงนี้ MinIO ทำได้หมดอยู่แล้ว
เราจะได้ไม่ต้องมาเสียเวลา implement features ที่เป็น General แบบนี้ครับ และมันก็เป็น open source ใช้ฟรีอยู่แล้วด้วย
src/providers/minio/minio.provider.ts
import { DateTime, Duration, Effect } from "effect"import * as Minio from "minio"import { getEnvs } from "@/shared/config"import { MinIOGetPresignedPutObjectError, MinIOGetUploadIdForMultiPartError } from "./minio.errors"
export class MinIOProvider extends Effect.Service<MinIOProvider>()("MinIO/Provider", { dependencies: [], effect: Effect.gen(function* () { const config = getEnvs()
const minioClient = new Minio.Client({ accessKey: config.MINIO_ACCESS_KEY, endPoint: config.MINIO_ENDPOINT, port: config.MINIO_PORT, secretKey: config.MINIO_SECRET_KEY, useSSL: false, })
const urlExpiresIn = Duration.minutes(1) const getUploadUrlForSmallFile = (bucketName: string, filename: string) => { return Effect.tryPromise({ catch: MinIOGetPresignedPutObjectError.new(), try: () => minioClient.presignedPutObject(bucketName, filename, Duration.toSeconds(urlExpiresIn)), }).pipe( Effect.tapError(Effect.logError), Effect.andThen(presignedUrl => ({ expiresIn: DateTime.unsafeFromDate(new Date()).pipe( DateTime.addDuration(urlExpiresIn), DateTime.toDateUtc, ).toISOString(), presignedUrl, })), ) }
const getUploadUrlForLargeFile = (bucketName: string, filename: string, mimeType: string) => { return Effect.tryPromise({ catch: MinIOGetUploadIdForMultiPartError.new(), try: () => minioClient.initiateNewMultipartUpload(bucketName, filename, { "Content-Type": mimeType }), }) }
return { getUploadUrlForLargeFile, getUploadUrlForSmallFile, minioClient, } }),}) { }
ตรงโค้ดผมขอไม่อธิบายนะครับ มันก็ไม่ได้มีอะไรพิเศษครับ ก็เป็น flow ของ MinIO ปกติครับ
ส่วน Error ของ MinIO ก็ตามนี้เลย
src/providers/minio/minio.errors.ts
import { Data } from "effect"import { EffectHelpers } from "@/shared/effect"
export class FileToArrayBufferConversionError extends Data.TaggedError("Minio/FileToArrayBuffer/Conversion/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class MinIOGetPresignedPutObjectError extends Data.TaggedError("Minio/Get/PreSigned/PutObject/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class MinIOGetUploadIdForMultiPartError extends Data.TaggedError("Minio/Get/UploadId/From/MultiPart/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
implement feature TaskFile
ผมอยากให้ Task แต่ละอันสามารถมีรูปภาพได้ด้วยครับ
โดยจะให้ upload รูปภาพทีหลัง แยกกันไปเลย
คือต้องมี task ก่อน แล้วค่อย upload รูปภาพ โดยระบุ TaskId คู่กันไปกับรูปภาพครับ
ผมจะเรียก feature นี้ว่า TaskFile นะครับ
โค้ดในส่วนของ TaskFile เช่น Schema, Servie, Routes ผมจะไม่อธิบายเยอะนะครับ
เพื่อนลองค่อยๆดู code ไปนะครับ สุดท้ายถ้ายังงงๆ ก็ DM มาถามได้ครับ
มาทำ TaskFile Schema กันก่อนเลย
TaskFile Schema
src/features/task-files/taskFile.schema.ts
/* eslint-disable ts/no-redeclare */import * as S from "effect/Schema"import { getEnvs } from "@/shared/config"import { EffectHelpers } from "@/shared/effect"
function isValidURL(url: string) { try { const _ = new URL(url) return true } catch { return false }}
const envs = getEnvs()const StoragePath = S.String.pipe( S.transform( S.String.pipe(S.brand("MinIO/StoragePath")), { decode: (url) => { if (isValidURL(url)) { return url } return `${envs.MINIO_SERVER_URL}${url}` }, encode: (url) => { if (isValidURL(url)) { const newUrl = new URL(url) return newUrl.pathname } return url }, }, ),)
export const TaskFileSchema = S.Struct({ id: S.Number, storagePath: StoragePath, taskId: S.Number,})
export type TaskFileSchema = typeof TaskFileSchema.Typeexport type TaskFileSchemaEncoded = typeof TaskFileSchema.Encoded
export const taskFileSchemaConvertor = EffectHelpers.convertFrom(TaskFileSchema)export const TaskFileSchemaStd = S.standardSchemaV1(TaskFileSchema)
export const TaskFileArraySchema = S.Array(TaskFileSchema)export type TaskFileArraySchema = typeof TaskFileArraySchema.Typeexport type TaskFileArraySchemaEncoded = typeof TaskFileArraySchema.Encodedexport const taskFileArraySchemaConvertor = EffectHelpers.convertFrom(TaskFileArraySchema)export const TaskFileArraySchemaStd = S.standardSchemaV1(TaskFileArraySchema)
TaskFile Errors
src/features/task-files/taskFile.errors.ts
import { Data } from "effect"import { EffectHelpers } from "@/shared/effect"
export class TaskFileCreateError extends Data.TaggedError("TaskFile/Create/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
export class TaskFileGetAllError extends Data.TaggedError("TaskFile/GetAll/Error")<EffectHelpers.ErrorMsg> { static new = EffectHelpers.createErrorFactory(this)}
TaskFile Service
ที่ service นี้จะเอา MinIO Provider มาใช้งานครับ
src/features/task-files/taskFile.service.ts
import type { TaskId } from "../tasks/task.schema"import { Effect } from "effect"import { MinIOProvider } from "@/providers/minio/minio.provider"import { PrismaProvider } from "@/providers/prisma/prisma.provider"import { TaskFileCreateError, TaskFileGetAllError } from "./taskFile.errors"import { taskFileArraySchemaConvertor } from "./taskFile.schema"
export class TaskFileService extends Effect.Service<TaskFileService>()("Service/TaskFile", { dependencies: [MinIOProvider.Default, PrismaProvider.Default], effect: Effect.gen(function* () { const minio = yield* MinIOProvider const { prismaClient } = yield* PrismaProvider const TASK_BUCKET_NAME = "uploads" as const
const getFilePath = (filename: string) => `/${TASK_BUCKET_NAME}/${filename}` const createTaskFile = (taskId: TaskId, filename: string) => Effect.tryPromise({ catch: TaskFileCreateError.new(), try: () => prismaClient.file.create({ data: { storagePath: getFilePath(filename), taskId, }, }), })
const getUploadUrlForSmallFile = (filename = Bun.randomUUIDv7()) => minio.getUploadUrlForSmallFile(TASK_BUCKET_NAME, filename)
const getAllTaskFile = (taskId: TaskId, options: { page: number, itemsPerPage: number }) => Effect.tryPromise({ catch: TaskFileGetAllError.new(), try: () => prismaClient.file.findMany({ skip: (options.page - 1) * options.itemsPerPage, take: options.itemsPerPage, where: { taskId, }, }), }).pipe( Effect.andThen(taskFileArraySchemaConvertor.fromObjectToSchemaEffect), )
return { createTaskFile, getAllTaskFile, getUploadUrlForSmallFile, } }),
}) { }
TaskFile Routes
src/features/task-files/taskFile.route.ts
import { Effect, pipe } from "effect"import * as S from "effect/Schema"import { ExitHelpers } from "@/shared/effect"import { orpcBase } from "@/shared/orpc/base"import { AppRuntime } from "@/shared/runtime"import { TaskId } from "../tasks/task.schema"import { TaskFileArraySchemaStd, TaskFileSchemaStd } from "./taskFile.schema"import { TaskFileService } from "./taskFile.service"
const tags = ["Task File"]
const getUploadUrlForSmallFileRoute = orpcBase45 collapsed lines
.route({ description: "get upload url for small file", // inputStructure: "detailed", method: "POST", path: "/get-upload-url-for-small-file", successStatus: 201, tags, }) .input( pipe( S.Struct({ filename: S.String.pipe(S.optional), }), S.standardSchemaV1, ), ) .output( pipe( S.Struct({ expiresIn: S.String, presignedUrl: S.String, }), S.standardSchemaV1, ), ) .errors({ BAD_REQUEST: { data: S.Unknown.pipe(S.standardSchemaV1), message: "request body invalid", }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), message: "something wrong for the server", }, }) .handler(async ({ errors, input }) => { const res = await TaskFileService.pipe( Effect.andThen(svc => svc.getUploadUrlForSmallFile(input.filename)), Effect.catchTags({ "Minio/Get/PreSigned/PutObject/Error": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), }), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError)
return res })
const createTaskFileRoute = orpcBase.route({38 collapsed lines
description: "create taskFile", inputStructure: "detailed", method: "POST", path: "/", successStatus: 201, tags,}) .input( pipe( S.Struct({ body: S.Struct({ filename: S.String, taskId: TaskId, }), }), S.standardSchemaV1, ), ) .output( TaskFileSchemaStd, ) .errors({ BAD_REQUEST: { data: S.Unknown.pipe(S.standardSchemaV1), }, INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, }) .handler(async ({ errors, input }) => { const res = await TaskFileService.pipe( Effect.andThen(svc => svc.createTaskFile(input.body.taskId, input.body.filename)), Effect.catchTags({ "TaskFile/Create/Error": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), }), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError) return res })
const getAllTaskFile = orpcBase.route({42 collapsed lines
description: "get all taskFile by task's id", inputStructure: "detailed", method: "GET", path: "/by-task/:taskId", successStatus: 200, tags,}) .input( pipe( S.Struct({ params: S.Struct({ taskId: S.NumberFromString, }), query: S.Struct({ itemsPerPage: S.Number.pipe(S.optionalWith({ default: () => 10 })), page: S.Number.pipe(S.optionalWith({ default: () => 1 })), }), }), S.standardSchemaV1, ), ) .output( TaskFileArraySchemaStd, ) .errors( { INTERNAL_SERVER_ERROR: { data: S.Unknown.pipe(S.standardSchemaV1), }, }, ) .handler(async ({ errors, input }) => { const res = await TaskFileService.pipe( Effect.andThen(svc => svc.getAllTaskFile(TaskId.make(input.params.taskId), input.query)), Effect.catchTags({ "ParseError": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), "TaskFile/GetAll/Error": error => Effect.fail(errors.INTERNAL_SERVER_ERROR({ data: error })), }), AppRuntime.runPromiseExit, ).then(ExitHelpers.getDataOrThrowRawError)
return res })
export const taskFilesRoutes = { createTaskFileRoute, getAllTaskFile, getUploadUrlForSmallFileRoute,}
merge all routes
แล้วเราก็เอา routes ของ TaskFile มาใส่รวมไว้กับ routes อื่นๆก่อนหน้านี้
src/features/index.ts
import { orpcBase } from "@/shared/orpc/base"import { taskFilesRoutes } from "./task-files/taskFile.route"import { taskRoutes } from "./tasks/task.route"
export const routes = { taskFiles: orpcBase.prefix("/task-files").router(taskFilesRoutes), tasks: orpcBase.prefix("/tasks").router(taskRoutes),}
ลองเปิด Scalar docs ดู

RPC Client example
ถ้าใครใช้ Monorepo ผมมีตัวอย่างสำหรับใช้ oRPC Client ให้แบบนี้ครับ
อยาก get, create, update data ก็จะ type-safe ได้เลยครับ
import type { RouterClient } from "@orpc/server"import type { routes } from "@/features"import { createORPCClient, safe } from "@orpc/client"import { RPCLink } from "@orpc/client/fetch"
const link = new RPCLink({ headers: () => ({ authorization: "Bearer token", }), url: "http://localhost:3333/rpc",})
const client: RouterClient<typeof routes> = createORPCClient(link)
const { data, error, isDefined, isSuccess } = await safe(client.tasks.createTaskRoute({ body: { desc: "", title: "test", },}))
console.log("data", data)
Github repo
code ตัวอย่างทั้งหมดนี้ผมเอาไว้ใน github repo นี้ครับ
Thank you
ขอบคุณทุกคนที่อ่านมาจนจบ
หวังว่าจะได้ความรู้ ได้ตัวอย่าง การใช้ oRPC กับ Effect มากขึ้นนะครับ
ถ้าชอบก็แชร์ได้นะครับ
ถ้าไม่รบกวนก็ Like FB Page กับ Subscribe Youtube ให้ด้วยนะครับ 🙏🏼🙏🏼🙏🏼
