Skip to content

Backend oRPC + Effect + Typescript Tutorial

turborepo ep1 cover
#oRPC#Effect#Hono#Typescript
CodeSookPublish: 4th June 2025

ก่อนหน้านี้เมื่อตอนต้นปี 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 นี้ผมจะใช้ 🍞 Bun นะครับ

initialize project

Terminal window
mkdir orpc-backend
cd orpc-backend
git init
bun init

จะได้แล้วก็ให้เลือกตัวเลือกแบบนี้

Terminal window
bun init
? Select a project template - Press return to submit.
Blank
React
Library
package name (orpc-backend): orpc-backend
entry point (src/index.ts): src/index.ts

เสร็จแล้ว terminal ก็น่าจะขึ้นแบบนี้

Terminal window
bun init
Select a project template: Library
package name (orpc-backend): orpc-backend
entry 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 ได้สะดวกขึ้น

tsconfig.json
{
"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 กัน

Terminal window
bunx @antfu/eslint-config@latest

ก็เลือกไปตามนี้

Terminal window
bunx @antfu/eslint-config@latest
@antfu/eslint-config v4.13.1
fatal: 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

Terminal window
bun add -D eslint-plugin-perfectionist

จะได้ไฟล์ eslint.config.js มา ก็แก้ให้เป็นตามนี้

ตรงนี้ใครอยากเพิ่ม rules อะไรก็ใส่ได้เลย

eslint.config.js
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

ผมจะใช้ dotenvx ในการจัดการกับ env file นะครับ

recap สั้นๆ dotenvx คือ lib ที่เอาไว้ encrypt ไฟล์ .env โดยเฉพาะ คือเราต้องมี private key, public key จึงจะสามารถแกะ .env ที่ encrypt ไว้ได้ พอ encrypt .env แล้วทำให้เราเอา .env ขึ้นไปไว้บน git ได้ ทำให้ shared .env กับทีมได้เลย พอ encrypt แล้วเราจะได้ .env.keys มาชุดนึง keys ตัวนี้เราจะไม่เอาขึ้นไปบน git ไม่งั้นๆใครๆก็แกะ .env ได้หมด แล้วค่อยส่ง keys ให้เพื่อนร่วมทีมอีกทีนึง ทำให้เวลา .env มันถูกเพิ่มอะไรเข้ามาเราก็ไม่ต้องไปบอกเพื่อนร่วมทีมแล้ว ขอแค่เขามี keys ก็จะเห็นเลยว่าเราเพิ่มอะไรเข้าไปบ้าง

Terminal window
bun add -D @dotenvx/dotenvx

แล้วก็สร้างไฟล์ .env, .env.uat, .env.prod ด้วยคำสั่ง

Terminal window
touch .env && \
touch .env.uat && \
touch .env.prod

เราจะได้ env files แบบนี้

Terminal window
.
├── .env
├── .env.prod
├── .env.uat

ใน package.json ก็ให้เพิ่ม dotenvx scripts แบบนี้

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"
}

จะมีเยอะหน่อยนะครับ เพราะรวม env ของ 3 environment servers เลย ec คือ encrypt dc คือ decrypt

เพิ่ม .env.keys ไปที่ .gitignore ด้วยนะ ส่วน .env เราเอาขึ้น git ได้เลย ก็ลบไปซะ

.gitignore
Terminal window
# 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

Terminal window
bun add -D husky && bunx husky init

จะมี folder .husky เพิ่มมาละ

Terminal window
.
├── .husky
├── _
└── pre-commit

สร้างไฟล์ .husky/install.mjs

.husky/install.mjs
.husky/install.mjs
// Skip Husky install in production and CI
if (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
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
pre-commit
bun env:all:ec
git add .

จะพักไว้ก่อน

install & setup commitlint

ติดตั้งด้วยคำสั่ง

Terminal window
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
Terminal window
bunx commitlint --edit "$1"

ทีนี้พอเราสั่ง git commit -m "this is commit msg" ตัว commitlint ก็จะไปเช็ค commit msg ของเราว่าตรงตามกฏของ gitmoji หรือเปล่า ซึ่งตามตัวอย่างนี้จะไม่ตรงกับกฏของ gitmoji นะครับ จะทำให้ git commit ไม่ผ่าน

แล้วกฏที่ว่าเป็นยังไง ไปดูได้ที่ commitlint-gitmoji

แต่การที่จะเขียนตามกฏด้วยตัวเองนั้นมันก็จำไม่ได้ ผมเลยใช้ตัวช่วยเพื่อให้การเขียน commit msg นั้นเป็นไปตามกฏได้ง่ายขึ้นผ่านการตอบคำถามผ่าน cli

ตัวช่วยที่ว่าก็คือ Commitizen ครับ

Commitizen

Commitizen เป็น cli tool ที่ช่วยสร้างคำถาม ให้เราป้อนคำตอบ แล้วมันจะเอาคำตอบไปต่อๆกัน เป็น commit msg ที่ตรงตามกฏ จริงๆ ที่ตรงตามกฏเพราะเราใส่ config ไว้ล่วงหน้าให้มันตรงตามกฏไว้เอง ตัว Commitzen แค่ช่วยสร้างคำถาม แล้วเอาคำตอบมาต่อๆกันเฉยๆ

ติดตั้งด้วยคำสั่ง

I like fish shell
bun add -D commitizen cz-customizable

แล้วก็เพิ่ม script ใน package.json อีก

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
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

เวลาใช้งาน ก็จะต้องสั่งแบบนี้

Terminal window
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 ไฟล์ เราจะต้องไปไล่เช็คทุกๆไฟล์ ก็คงจะเกินความจำเป็น ตรงนี้ผมก็จะใช้ lint-staged มาช่วย จะทำให้ eslint ไปไล่เช็คเฉพาะไฟล์ที่เราแก้ไขไปเท่านั้น

ติดตั้งด้วยคำสั่ง

Terminal window
bun add -D lint-staged

สร้าง config file ที่ชื่อว่า lint-staged.config.mjs

lint-staged.config.mjs
lint-staged.config.mjs
const config = {
"*.{js,ts,jsx,tsx}": "eslint .",
};
export default config;

แล้วก็ไปแก้ script ใน package.json ให้เป็นแบบนี้

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 โดยจะใช้ Cache-Aside strategy นะครับ ง่ายดี
attached file โดยใช้ MinIO, โดยในแต่ละ Task ผมอยากให้แนบไฟล์ได้ด้วยนะครับ โดยจะเก็บเป็น link ส่วนไฟล์จะอยู่ที่ MinIO ครับ

Docker compose for all services

ผมเตรียม docker compose มาให้แล้ว สำหรับ Postgres, Redis, MinIO

เพิ่ม folder data ไปที่ .gitignore ก่อนเลย

.gitignore
.gitignore
dev-services/data
dev-services/compose.yaml
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

Redis
  • host: localhost
  • port: 6379
UI
  • address: เปิด Admin UI browser ด้วย link นี้ Redis insight

Minio

Server
  • host: localhost
  • port: 9000
UI
  • address: เปิด MinIO console UI ใน browser ด้วย link นี้ MinIO Console

แล้วก็สั่ง start services ได้เลยด้วยคำสั่ง

Terminal window
docker compose up -d

Folder structure

มาดูการวางไฟล์และโฟลเดอร์ของโปรเจคนี้กันก่อน

folder 1 folder 2

อันนี้เป็น files & folders จากโปรเจคตัวอย่างที่จะทำกันหลังจากนี้

Terminal window
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 นี้ ทั้งหมดเลย ก็ตามนี้

Terminal window
bun add @orpc/client @orpc/openai @orpc/server @prisma/client effect hono minio prisma redis

Setup build script

เรามา setup build script กันก่อน
ซึ่งใน Bun จะไม่มี config สำหรับสั่ง build แต่เราสามารถสร้างไฟล์ .ts แล้วใส่ build config ในไฟล์ .ts นั้นได้เลย
ในที่นี้ผมจะสร้างไฟล์ build.ts ไว้ใน root folder นะครับ
แล้วก็มี config แบบนี้

build.ts
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
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 ให้ สั่ง

Terminal window
bun run build

จะสั่ง bun build เฉยๆไม่ได้นะ ต้องมี run ด้วย เพราะคำสั่ง script มันไปซ้ำกับ command build ของ bun

Setup Prisma

ติดตั้ง prisma

Terminal window
bun add prisma
bunx prisma init --datasource-provider postgresql

เราจะได้ folder prisma แบบนี้

Terminal window
src
└─ prisma
└── schema.prisma

ใน schema.prisma ก็จะใส่ model แบบนี้

prisma/schema.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 ละ

แล้วก็สั่ง

Terminal window
bunx prisma db push
bunx prisma generate

เบื้องต้นตอนนี้เรายังอยู่ในช่วง development ผมก็เลยใช้ prisma db push ไปก่อนเลย เผื่อว่า table เรายังมีการเปลี่ยนแปลงเพิ่มเติม
ถ้าหากว่าน่าจะไม่เปลี่ยนแล้ว ก็จะสั่ง prisma migrate dev ในตอนนั้น

เราจะได้ files และ folders เพิ่มเข้ามาที่ providers/ แบบนี้

I like fish shell
src/providers
└── prisma
├── generated/

Install Effect

ผมจะใช้ Effect ในทุกๆ project เหมือนเดิมครับ ไปติดตั้งกันเลย

install effect
bun add effect

Effect มี functions เยอะมาก ผมก็จะมี function ที่เรียกใช้บ่อยๆ ก็เลยจะทำ Helper functions เอาไว้ให้เรียกใช้ได้ง่ายขึ้นอีก
ไปสร้าง Effect helper functions กันเลย

src/shared/effect/helpers.ts
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 ไม่สำเร็จเราจะได้ error ParseError มา

ถัดมา ยังมี Helper อีกตัวหนึ่งคือ Exit helpers

Exit เป็น monad ตัวนึงใน Effect มักจะใช้ตอนสุดท้ายของ program เพื่อไม่ให้ program crash หรือ throw error เราจะใช้ Exit มาช่วย
ก็เลยจะมี Exit helpers แบบนี้

src/shared/effect/exit-helpers.ts
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
src/shared/effect/index.ts
export * as ExitHelpers from "./exit-helpers"
export * as EffectHelpers from "./helpers"

ตอนนี้ที่ folder src/shared/effect/ ก็จะมี files แบบนี้

Terminal window
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
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
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
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 ก็ใส่ลงไปใน method new() ได้เลย เช่น 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 แบบนี้นะครับ

Terminal window
├── 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
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.Type
export type TaskEncoded = typeof TaskSchema.Encoded
export 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.Type
export type TaskArrayEncoded = typeof TaskArraySchema.Encoded
export 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.Type
export type TaskCreateEncoded = typeof TaskCreateSchema.Encoded
export 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
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
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
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
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 แบบครับ

  1. RPC Handler
  2. 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 อีกทีนึงครับ

Terminal window
src
└── shared
├── orpc
└── plugins
├── cors.ts
├── csrf.ts
├── index.ts
├── openapi-reference.ts
└── response-headers.ts

CORS Plugin

src/shared/orpc/plugins/cors.ts
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
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
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
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
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
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 จะมีไฟล์แบบนี้ครับ

Terminal window
src
└── shared
├── orpc
├── base.ts
├── handlers.ts
├── jsonSchema.ts
└── plugins
├── cors.ts
├── csrf.ts
├── index.ts
├── openapi-reference.ts
└── response-headers.ts

เดี๋ยวตอนหลังเราจะเพิ่ม Middleware กันด้วย

Task Routes

ต่อมา เราจะมาทำ oRPC route กันครับ
ซึ่ง 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
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
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
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
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 ต่างๆก่อน

.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-access
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_SECRET_KEY=uploads-secret-123

dev script

เรามาเพิ่ม dev script กันก่อน เพื่อให้ง่ายต่อการรัน server ด้วย

package.json
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 ด้วยคำสั่ง

Terminal window
bun dev

ลองเปิดไปที่ /api/docs

น่าจะเจอ Scalar แบบนี้

scalar image

more Task routes

เราจะมาทำ task routes เพิ่มกันครับ ยังเหลือ Get by id, Get all, Update, Delete ครับ

route Get Task by id

src/features/tasks/task.route.ts
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
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
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 อันนี้ครับ
โดยจะใช้ Cache-Aside Strategy นะครับ
ผมจะใช้ Redis เก็บ Cache data นะครับ

ฉนั้นตรงนี้จะขอพักไว้ก่อน
เราจะต้องไปสร้าง Redis provider และ Cache Middleware กันก่อน

Redis Provider

ซึ่งวิธีการสร้าง Redis provider ก็จะเหมือนกับ Prisma provider เลย
แต่จะมี function ที่ผมเขียนเพิ่ม เพื่อให้เรียกใช้ได้ง่ายๆครับ

src/providers/redis/redis.provider.ts
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() อันนี้อยากจะอธิบายสักหน่อยครับ
    function getOrSet() อันนี้จะรับ parameters 3 ตัวครับ

      1. key: string ก็เป็น key ไง
      1. fn: () => Effect.Effect<Data, Err> จะรับ function ที่ return Effect ครับ โดยทั่วไปก็จะเป็น function ที่เอาไว้ get data เป็น function ที่อยู่ใน Services อะแหละ
      1. ttl?: Option.Option<Duration.Duration> ก็จะเป็น ttl ของ cache นะครับ

การทำงานของ function นี้คือ จะไปดู cache ใน Redis โดยใช้ key ที่ได้รับมาก่อน ถ้าไม่ได้ของใน Redis ก็จะไปเรียก fn() ให้ทำงาน แล้วเอา data ที่ได้จาก fn() ไปใส่ Redis ตาม key ที่ระบุ แล้ว return data ที่ได้
ทำให้เราใช้ cache แบบ Cache-Aside ได้สะดวกขึ้นครับ

  • ส่วน function อื่นๆก็ตรงตัว ขอข้ามการอธิบายไปเลยนะครับ ถ้าใครยังสงสัยก็ DM มาถามกันได้ที่ page หรือโทรมาก็ยินดีครับ
  • และสุดท้ายยังมี Errors ที่เราเรียกใช้งาน แต่ยังไม่ได้สร้าง ก็จะพาไปสร้างในส่วนต่อไปนี่ละครับ

Redis Errors

src/providers/redis/redis.errors.ts
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 ก็จะมีไฟล์แบบนี้ครับ

Terminal window
src
├── providers
├── prisma
└── redis
├── redis.errors.ts
└── redis.provider.ts

oRPC Cache Middleware

ต่อมาเราจะสร้าง cache middleware กันครับ

src/shared/orpc/middlewares/cache.middleware.ts
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
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
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 ดู
จะได้แบบนี้

scalar 2

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
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
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
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.Type
export 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.Type
export type TaskFileArraySchemaEncoded = typeof TaskFileArraySchema.Encoded
export const taskFileArraySchemaConvertor = EffectHelpers.convertFrom(TaskFileArraySchema)
export const TaskFileArraySchemaStd = S.standardSchemaV1(TaskFileArraySchema)

TaskFile Errors

src/features/task-files/taskFile.errors.ts
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
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
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 = orpcBase
45 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
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 ดู

scalar3

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 ให้ด้วยนะครับ 🙏🏼🙏🏼🙏🏼


Crafted with care 📝❤️ ✨ bycode sook logoCodeSook