ข้ามไปยังเนื้อหา

Actions

การจะเรียก server logic จาก client แบบเดิม คุณต้องเขียน endpoint, คิด URL, fetch() เอง, stringify body, เดา type ของ response และ validate input ด้วยมือบน server Actions ยุบทั้งหมดนั้นเหลือ function ที่ typed ตัวเดียว: define บน server แล้วเรียกจาก client ด้วย type-safety เต็มและ validation อัตโนมัติ

ก่อนพูดถึง syntax: การเรียก actions.createComment(...) จาก client code ของคุณเอง กับการ curl ไปที่ URL ของ action ตรง ๆ คือ request แบบเดียวกันในสายตาของ server Astro compile ทุก action เป็น HTTP endpoint จริงที่ /_actions/<name> — ใครก็เข้าถึงได้ถ้ารู้ (หรือเดา) ชื่อ ไม่ว่าจะเคยโหลดหน้าของคุณหรือไม่ก็ตาม action ไม่ใช่ channel ส่วนตัวระหว่าง UI กับ server ของคุณ — มันคือ public POST endpoint ที่บังเอิญมี TypeScript binding ให้

นั่นแปลว่า การ authorize ไม่ใช่ของแต่งเพิ่ม มันคือบรรทัดแรกของ handler — เช็คว่าใครเรียกก่อนแตะฐานข้อมูล ในทุก action ตัวเดียว ตั้งแต่ตัวแรกที่คุณเขียน:

src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro/zod';
// Stand-in for your real database client — replace with your ORM/query layer.
const db = { comments: { create: async (data: { postId: string; body: string; userId: string }) => ({ id: crypto.randomUUID(), ...data }) } };
export const server = {
createComment: defineAction({
input: z.object({
postId: z.string(),
body: z.string().min(1).max(2000),
}),
handler: async (input, context) => {
// Reject first — before anything else runs. `locals.user` is set by
// middleware (see the middleware & sessions lesson); it is `null` for
// anyone without a valid session, curl included.
if (!context.locals.user) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'You must be signed in to comment.',
});
}
// input is fully typed and already validated against the schema.
const comment = await db.comments.create({
postId: input.postId,
body: input.body,
userId: context.locals.user.id,
});
return comment; // the return type flows to the client
},
}),
};

พิสูจน์กับ build จริงของ handler นี้เป๊ะ ๆ — POST ที่ไม่มี session cookie เลย:

$ curl -s -X POST http://127.0.0.1:4351/_actions/createComment \
-H "Content-Type: application/json" -d '{"postId":"42","body":"hi"}' \
-w "\nHTTP %{http_code}\n"
{"type":"AstroActionError","code":"UNAUTHORIZED","status":401,"message":"You must be signed in to comment."}
HTTP 401

สองอย่างยังเป็นอัตโนมัติแม้เพิ่ม auth check แล้ว: input ถูก validate กับ Zod schema ก่อน handler รัน และ return type ของ handler ถูก infer ไปจนถึงผู้เรียก — ไม่ต้อง type response ด้วยมือ

ActionError รับ code ที่อ่านออก (จาก set คงที่ที่ map ไปยัง HTTP status จริง) แทนตัวเลขเปล่า ๆ — list เดียวกับที่ action และ endpoint ใช้ร่วมกัน:

Code Status ใช้เมื่อ
BAD_REQUEST 400 request ผิดรูปแบบนอกเหนือจากที่ input schema เช็ค
UNAUTHORIZED 401 ไม่มี session ที่ valid — พิสูจน์แล้วข้างบน
FORBIDDEN 403 มี session valid แต่ไม่มีสิทธิ์ทำ เรื่องนี้
NOT_FOUND 404 row ที่จะแก้ไม่มีอยู่ (หรือไม่ใช่ของ user นี้)
CONFLICT 409 เขียนซ้ำหรือข้อมูลเก่ากว่าที่คาด
TOO_MANY_REQUESTS 429 rate limiting
INTERNAL_SERVER_ERROR 500 default ถ้า throw Error เปล่า

การ validate input ล้มเหลวเป็น error shape อีกแบบ ที่เป็นอัตโนมัติ — isInputError narrow ไปยังมันได้:

import { actions, isInputError } from 'astro:actions';
const { data, error } = await actions.createComment({ postId: '42', body: '' });
if (isInputError(error)) {
// error.fields is keyed by input field name, each an array of messages.
console.log(error.fields.body); // ["Too small: expected string to have >=1 characters"]
} else if (error) {
// Any other ActionError — check error.code.
console.log(error.code);
}

พิสูจน์กับ handler ตัวเดียวกัน คราวนี้ body ว่าง (ผิดกฎ .min(1)) — shape จริงที่ได้เมื่อเรียก endpoint /_actions/createComment ตรง ๆ:

$ curl -s -X POST http://127.0.0.1:4351/_actions/createComment \
-H "Content-Type: application/json" -d '{"postId":"42","body":""}'
{"type":"AstroActionInputError","issues":[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["body"],"message":"Too small: expected string to have >=1 characters"}],"fields":{"body":["Too small: expected string to have >=1 characters"]}}

เมื่อไม่อยากแยก branch ด้วย { data, error } เลย — เช่นใน code ที่มี try/catch ของตัวเองอยู่แล้ว — เรียก .orThrow() แทน มัน return data ตรง ๆ และ throw ActionError (หรือ error ทั่วไป) เมื่อ fail:

try {
const comment = await actions.createComment.orThrow({ postId: '42', body: 'Nice post!' });
console.log('Created comment', comment.id);
} catch (e) {
// e is the ActionError instance when the server threw one.
}
sequenceDiagram
  participant C as Client
  participant A as Astro Action
  participant H as handler
  C->>A: actions.createComment(input)
  A->>A: validate input against Zod schema
  A->>H: run handler(input, context)
  H->>H: reject if !locals.user (ActionError UNAUTHORIZED)
  H-->>A: return value (typed)
  A-->>C: { data, error }
การเรียกจาก client รัน handler ที่ validate และ authorize แล้ว

Action เชื่อมกับ HTML <form> ตรง ๆ ได้ด้วยการส่งมันเป็น prop action — ทำงานเป็น form POST ปกติแม้ JavaScript ยังไม่โหลด แล้วอัปเกรดเป็น client call เมื่อ hydrate นี่คือ progressive enhancement: form submit ได้ทั้งสองทาง อ่านผลของ POST-แล้ว-redirect-กลับ ด้วย Astro.getActionResult:

src/pages/index.astro
---
import { actions } from 'astro:actions';
const result = Astro.getActionResult(actions.createComment);
---
<form method="POST" action={actions.createComment}>
<input type="hidden" name="postId" value="42" />
<textarea name="body"></textarea>
<button type="submit">Comment</button>
</form>
{result?.error && <p class="error">{result.error.code}: {result.error.message}</p>}

getActionResult อ่านผลของ action ที่เพิ่ง submit ไปยังหน้านี้ (Astro ห่อมันไว้ให้หลัง redirect) — เป็น undefined ตอนโหลดหน้าปกติ และมีค่าทันทีหลัง POST

import actions ตัวเดียวกันเป๊ะใช้ได้จาก framework component ที่ hydrate แล้ว — Astro แปลงการเรียกเป็น fetch() ให้เอง:

// src/components/LikeButton.tsx (a Preact island, client:visible)
import { actions } from 'astro:actions';
export default function LikeButton({ postId }: { postId: string }) {
return (
<button onClick={() => actions.likePost({ postId })}>
Like
</button>
);
}

สำหรับกรณีที่ await fetch ปกติไม่ได้ — ยิง request ตอน unload หรือ ping “mark as read” เบื้องหลัง — getActionPath ให้ URL ดิบมาใช้กับ navigator.sendBeacon ซึ่งยิง request โดยไม่รอ (หรือ block บน) response:

<script>
import { actions, getActionPath } from 'astro:actions';
navigator.sendBeacon(
getActionPath(actions.likePost),
new Blob([JSON.stringify({ postId: '42' })], { type: 'application/json' }),
);
</script>

เมื่อ snippet ที่ generate มา define action ให้เช็คสิ่งเหล่านี้ก่อนเชื่อ:

  • handler เช็ค context.locals.user (หรือ auth check ที่เทียบเท่า) เป็นบรรทัดแรกหรือเปล่า หรือสมมติว่า caller login แล้วเพราะ form render เฉพาะหลัง login? การ render form เฉพาะ user ที่ login ควบคุมแค่ว่า UI โชว์อะไร ไม่ใช่ว่า endpoint จะรับ request ไหน — action เองยังถูกเรียกตรงด้วย POST ได้อยู่ดี
  • ถ้า action แก้หรือลบ row เช็ค query กรองด้วย id ของ user ปัจจุบันด้วยไหม นอกจาก id ของ row — หรือ user ที่ login คนไหนก็แก้ข้อมูลของคนอื่นได้?
  • ทุก field ถูกอ่านผ่าน Zod input schema หรือ handler เอื้อมไปอ่าน context.request แล้ว parse body เองข้าม validation?
  • fetch() ดิบไปที่ /_actions/<name> ถูกปฏิบัติเหมือนว่า return JSON ธรรมดาหรือเปล่า? มัน return ข้อมูลที่ encode ด้วย devalue — มีแค่ actions.name(...) (หรือ .orThrow()) เท่านั้นที่ decode ให้ ตามที่พิสูจน์ข้างบน
  • ActionError ถูก throw ด้วย code จริงจากตารางข้างบน หรือ string ที่คิดขึ้นเองซึ่งจะไม่ map ไปยัง HTTP status ที่สมเหตุสมผล?

🐛 หา bug ในโค้ดนี้

src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';
export const server = {
deletePost: defineAction({
input: z.object({ postId: z.string() }),
handler: async (input, context) => {
// "the delete button only shows for the post's author, so this is safe"
await db.posts.delete({ where: { id: input.postId } });
return { ok: true };
},
}),
};
เฉลย

comment อธิบาย bug จริง ๆ ไว้เอง: การซ่อนปุ่มลบใน UI ควบคุมแค่ว่า author ที่ login อยู่จะเห็นอะไร ไม่ใช่ว่า endpoint /_actions/deletePost จะรับอะไร ไม่มีจุดไหนเช็ค context.locals.user เลย ดังนั้น caller ที่ authenticate แล้วคนไหน — หรือแม้แต่ curl request ที่ไม่ authenticate เลยเพราะไม่มี UNAUTHORIZED check เลยด้วยซ้ำ — ลบ post ไหนก็ได้ด้วยการเดาหรือไล่ postId แก้ตามแบบ createComment ข้างบน: reject เมื่อ !context.locals.user ด้วย ActionError({ code: 'UNAUTHORIZED' }) แล้วกรอง delete ด้วย userId: context.locals.user.id ด้วย เพื่อให้ user ที่ login ลบได้เฉพาะ post ของตัวเอง (ActionError({ code: 'FORBIDDEN' }) หรือ where ที่แค่ไม่ match อะไรเลยก็ใช้ได้ — NOT_FOUND สำหรับ row ที่ไม่ใช่ของ user ยังช่วยไม่ leak ด้วยว่า id นั้นมีอยู่จริง)

Action คือปุ่มที่บังเอิญ compile ออกมาเป็น HTTP endpoint ไม่ใช่ HTTP endpoint ที่บังเอิญมีปุ่ม function signature ที่คุณเขียนคือ signature เดียวกับที่คำสั่ง curl ของคนแปลกหน้าเรียกได้ — พิสูจน์แล้วข้างบนด้วย 401 จริง ถ้าคุณไม่ยอมรับ field จาก POST body ที่ไม่ระบุตัวตน ก็อย่ายอมรับมันจาก input object ที่ validate แล้วเช่นกัน — validation พิสูจน์แค่ว่า รูปแบบ ถูก ไม่ใช่ว่า ผู้เรียก มีสิทธิ์ทำเรื่องนี้

Action API endpoint
คุณเขียน function ตัวเดียว เรียกตรงจาก form หรือ client code URL ที่ต่อสายและ fetch เอง
Validation อัตโนมัติ จาก Zod input schema ทำมือใน handler body
รูปแบบ response { data, error } ที่ infer แล้ว, encode ด้วย devalue บน wire Response อะไรก็ได้ที่คุณสร้าง
Progressive enhancement มีในตัวผ่าน <form action={actions.x}> ไม่มี — ต้องมี JavaScript เพื่อ fetch
เหมาะกับ mutation และ form ในแอปของตัวเอง public API, webhook หรือ client ที่ไม่ใช่ form
เข้าถึงได้จากสาธารณะ ได้ ผ่าน /_actions/<name> — พิสูจน์แล้วข้างบน ได้ ผ่าน path ของมัน
  • “แค่ form ของฉันเรียก action นี้ ก็ปลอดภัยแล้ว” พิสูจน์แล้วข้างบน: request แบบเดียวกันเป๊ะไปถึง handler จาก curl โดยไม่มี form และไม่มี session เลย — action ID คือสัญญาทั้งหมด
  • “ซ่อนปุ่มสำหรับคนที่ไม่ใช่เจ้าของก็พอแล้ว” การ gate ตอน render ควบคุมแค่ UI ที่โชว์ ไม่ใช่ request ที่ server รับ — ดู bug deletePost ข้างบน
  • “isInputError กับ ActionError ที่ throw เองหน้าตาเหมือนกันฝั่ง client” เป็นคนละ shape กัน (AstroActionInputError vs AstroActionError, พิสูจน์แล้วข้างบน) — isInputError narrow เฉพาะกรณี validation ล้มเหลว พร้อม fields แยกตาม field
  • “fetch() ไปที่ action path แล้วอ่าน JSON ปกติได้เลย” พิสูจน์แล้วข้างบน: endpoint ดิบ return ข้อมูลที่ encode ด้วย devalue — ใช้ actions.name(...) หรือ .orThrow() ซึ่ง decode ให้
define action ที่ไหน และแต่ละตัวต้องมีอะไร?
input ของ action เกิดอะไรก่อน handler รัน?
พิสูจน์ด้วย curl POST จริงไปที่ /_actions/createComment โดยไม่มี session cookie — response จริงคืออะไร?
handler: async (input, context) => {
  const comment = await db.comments.create({ body: input.body });
  return comment;
}
การเรียก endpoint /_actions/<name> ดิบ ๆ ด้วย curl ต่างจากการเรียก actions.name(...) จาก client code ยังไง?
อะไรแยก isInputError(error) ออกจาก ActionError({ code: 'UNAUTHORIZED' }) ที่ throw เอง?
การเชื่อม action กับ <form action={actions.name}> ให้อะไร และอ่านผลหลัง POST ยังไง?