การเขียนและ Invoke Function
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”Edge Function คือ Deno.serve handler ธรรมดาที่รับ HTTP request แล้วคืน response กลับไป เรียกใช้จาก client ด้วย supabase.functions.invoke() — แต่เพราะมันรันบน Deno การเขียนให้ถูกต้องแปลว่าต้อง import dependency และจัดการ cross-origin request แบบ Deno เอง
function จริงแบบขั้นต่ำ
หัวข้อที่มีชื่อว่า “function จริงแบบขั้นต่ำ”Deno.serve เป็น global ที่ Deno runtime มีให้ในตัว ไม่ต้อง import อะไรเพิ่ม คุณส่ง callback ที่รับ Request ที่เข้ามาแล้วคืน Response กลับไป เหมือน Fetch API ที่คุณคุ้นเคยจากฝั่ง browser อยู่แล้ว
Deno.serve((_req) => { return new Response(JSON.stringify({ message: 'Hello from the edge' }), { headers: { 'Content-Type': 'application/json' }, })})ไฟล์นี้อยู่ที่ supabase/functions/edge-hello/index.ts เมื่อ deploy แล้วจะเรียกได้ที่ https://<project-ref>.supabase.co/functions/v1/edge-hello แต่ปกติคุณไม่ต้องประกอบ URL นี้เอง เพราะ client library จัดการให้ deno check ยืนยันได้ว่าไฟล์นี้ type-check ผ่านด้วยตัวเองก่อนจะรันด้วยซ้ำ — พูดถึงท้ายบทนี้
Import specifier: นี่คือ Deno ไม่ใช่ Node
หัวข้อที่มีชื่อว่า “Import specifier: นี่คือ Deno ไม่ใช่ Node”วิธีที่โค้ด Edge Function ที่ AI สร้างพังบ่อยที่สุด: import package แบบเดียวกับที่ทำในโปรเจกต์ Node.js หรือ Vite
// @expect-error worker fails to boot: bare specifiers are not resolvable in Denoimport { createClient } from '@supabase/supabase-js'
Deno.serve((_req) => { const client = createClient('http://x', 'y') return new Response(JSON.stringify({ ok: !!client }))})deploy แบบนี้ตรง ๆ ภายใต้ supabase functions serve ทุก request ที่เข้า function นี้จะได้ 503 จริง และ error ตอน boot จริง ๆ ที่จับมาจาก log ของ runtime ในเครื่องก็บอกชัดเจนว่าต้องทำอย่างไรแทน
HTTP/1.1 503{"code":"BOOT_ERROR","message":"Worker failed to boot (please check logs)"}
worker boot error: failed to bootstrap runtime: failed to create the graph:Relative import path "@supabase/supabase-js" not prefixed with / or ./ or ../ hint: If you want to use a JSR or npm package, try running `deno add jsr:@supabase/supabase-js` or `deno add npm:@supabase/supabase-js` at file:///.../supabase/functions/edge-bare-import-demo/index.ts:1:30Deno ไม่มี bare-specifier/node_modules-style resolution มาให้โดย default ทุก import ต้องเป็น relative path (./foo.ts), URL เต็ม ๆ หรือ package-registry specifier สองแบบนี้
jsr:— registry ของ Deno เอง (JSR) และเป็นที่ที่@supabase/supabase-jsถูก publish ไว้สำหรับ Denonpm:— resolve npm package ธรรมดาผ่าน npm compatibility layer ของ Deno
แก้แล้ว ทั้งสอง specifier ทำงานได้จริงกับ local stack นี้ใน function เดียวกัน
import { createClient } from 'jsr:@supabase/supabase-js@2'import { nanoid } from 'npm:nanoid@5'
Deno.serve((_req) => { const client = createClient('http://example.com', 'anon-key-placeholder') return new Response(JSON.stringify({ jsrClientOk: !!client, npmId: nanoid(8) }), { headers: { 'Content-Type': 'application/json' }, })})$ curl "$FUNCTIONS_URL/edge-import-specifiers" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"jsrClientOk":true,"npmId":"kR-o8HyM"}deno.json: import map ต่อ function
หัวข้อที่มีชื่อว่า “deno.json: import map ต่อ function”bare specifier ยังใช้ได้ถ้าคุณให้ mapping กับ Deno — deno.json ที่วางไว้ข้าง index.ts ของ function ทำหน้าที่เป็น import map ของ function นั้นเท่านั้น (แต่ละ function มี module graph ของตัวเอง ไม่มีอะไรรั่วไปยัง function ข้างเคียง)
{ "imports": { "supabase": "jsr:@supabase/supabase-js@2" }}import { createClient } from 'supabase'
Deno.serve((_req) => { const client = createClient('http://example.com', 'anon-key-placeholder') return new Response(JSON.stringify({ ok: !!client }), { headers: { 'Content-Type': 'application/json' }, })})$ curl "$FUNCTIONS_URL/edge-import-map" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"ok":true}bare specifier 'supabase' resolve ได้เพราะ imports map ใน deno.json redirect มันไปที่ jsr: specifier จริง ๆ ลบไฟล์นี้ออก function นี้ตัวเดิมเป๊ะจะพังด้วย BOOT_ERROR แบบเดียวกับข้างบนทันที
CORS: header ที่ function ต้องตั้งเอง
หัวข้อที่มีชื่อว่า “CORS: header ที่ function ต้องตั้งเอง”browser ที่ยิง cross-origin request จะตรวจก่อนว่า server ยอมให้ทำแบบนี้หรือไม่ — สำหรับ request ที่ไม่ธรรมดา (เช่น POST ที่มี JSON body) browser จะส่ง preflight แบบ OPTIONS ไปก่อน แล้วค่อยยิง request จริงถ้า response ของ preflight บอกว่าทำได้ function ที่ไม่ตั้ง CORS header เลยจะทำงานได้สมบูรณ์จาก curl (curl ไม่บังคับ CORS — มีแค่ browser เท่านั้นที่บังคับ) แล้วพังแบบเงียบ ๆ สำหรับ browser client จริงจากคนละ origin จัดการให้ถูกต้อง
const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS',}
Deno.serve(async (req) => { if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }) }
const { name } = await req.json() return new Response(JSON.stringify({ message: `Hello ${name}!` }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, })})preflight จริงยิงเข้าไป และ header จริงที่ response ถืออยู่
curl -i -X OPTIONS "$FUNCTIONS_URL/edge-cors" \ -H "Origin: https://example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: authorization, content-type"HTTP/1.1 200 OKAccess-Control-Allow-Origin: *Access-Control-Allow-Headers: authorization, content-typeAccess-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS,TRACE,CONNECTrequest จริง ทั้งแบบที่ function ตั้ง CORS header เองและไม่ตั้ง — curl แสดงทั้งคู่เป็น 200 เพราะ curl ไม่บังคับ CORS เลย สิ่งที่ browser อ่าน header จะต่างกัน
$ curl -i -X POST "$FUNCTIONS_URL/edge-cors" -H "Origin: https://example.com" \ -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY" -H "Content-Type: application/json" -d '{"name":"world"}'HTTP/1.1 200 OKaccess-control-allow-origin: *access-control-allow-headers: authorization, x-client-info, apikey, content-typeaccess-control-allow-methods: POST, OPTIONS{"message":"Hello world!"}
$ curl -i -X POST "$FUNCTIONS_URL/edge-no-cors" -H "Origin: https://example.com" \ -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY" -H "Content-Type: application/json" -d '{"name":"world"}'HTTP/1.1 200 OKAccess-Control-Allow-Origin: *{"message":"Hello world!"}ทั้งคู่ได้ 200 พร้อม body — ความต่างที่ fetch() ของ browser จริงจะเอาไปใช้คือ response ของ edge-cors ถือ Allow-Headers/Allow-Methods ที่ POST cross-origin จริงพร้อม custom header ต้องมี ส่วน edge-no-cors มีแค่สิ่งที่ proxy ในเครื่องนี้เติมให้เท่านั้น deploy จริงแล้วเรียกจาก origin ที่ต่างกันจริงพร้อม custom header มีแค่ function ที่ตั้ง header ของตัวเองเท่านั้นที่การันตีว่าทำงานได้
การเรียกใช้จาก client
หัวข้อที่มีชื่อว่า “การเรียกใช้จาก client”supabase-js มี method functions.invoke() แยกออกมาแทนที่จะใช้ fetch เปล่า ๆ เพราะทำงานที่เป็นประโยชน์ให้อัตโนมัติ: serialize body เป็น JSON และแนบ header Authorization ให้อัตโนมัติ เป็น access token ของ user ที่ล็อกอินอยู่ตอนนั้น หรือเป็น anon key ของโปรเจกต์ถ้าไม่มีใครล็อกอิน
// @runimport { createClient } from '@supabase/supabase-js'
declare const process: { env: Record<string, string | undefined> }
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
const { data, error } = await supabase.functions.invoke('edge-hello')
if (error) { throw new Error(`invoke failed: ${error.message}`)}
console.log('invoke succeeded, function responded:', data)นี่คือการเรียกจริงที่ถูก execute จริง — supabase.functions.invoke('edge-hello') ไปถึง function edge-hello จากก่อนหน้านี้ในบทนี้ ที่ถูก serve ในเครื่องด้วย supabase functions serve process ของโมดูลนี้เอง และ harness รันมันด้วย tsx กับ live stack ตรงตามที่แสดงไว้ ไม่ใช่ client ปลอม
verify_jwt: ใครเรียกได้บ้าง
หัวข้อที่มีชื่อว่า “verify_jwt: ใครเรียกได้บ้าง”ก่อนที่โค้ดของ function จะรันเลย Edge Runtime บังคับ Supabase JWT ที่ valid บนทุก request ได้ ควบคุมต่อ function ด้วย verify_jwt (default เป็น true — ปิดไว้สำหรับ function ที่ถูกเรียกโดยอะไรที่ไม่ใช่ client ที่ล็อกอินแล้ว เช่น webhook receiver ในบทถัดไป)
[functions.edge-hello]verify_jwt = falseด้วยค่า default (verify_jwt = true ไม่ใส่ flag) response body จริงสำหรับ header Authorization ที่ขาดไปกับที่ผิดรูปแบบ — จับมาจาก local stack นี้ เรียก edge-hello
$ curl "$FUNCTIONS_URL/edge-hello"{"code":"UNAUTHORIZED_NO_AUTH_HEADER","message":"Missing authorization header","msg":"Missing authorization header"}HTTP 401
$ curl "$FUNCTIONS_URL/edge-hello" -H "Authorization: Bearer not-a-real-jwt" -H "apikey: $ANON_KEY"{"code":"UNAUTHORIZED_INVALID_JWT_FORMAT","message":"Invalid JWT format","msg":"Invalid JWT format"}HTTP 401
$ curl "$FUNCTIONS_URL/edge-hello" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"message":"Hello from the edge"}HTTP 200anon key ของโปรเจกต์เองก็เป็น JWT ที่ valid (signed สำหรับ role anon) นี่คือเหตุผลที่มันใช้เป็น bearer token ข้างบนได้เลย — verify_jwt ตรวจแค่ว่า token เป็น Supabase JWT จริงที่ signed ถูกต้อง ไม่ได้ตรวจว่ามี user จริงล็อกอินอยู่ การส่ง access token ของ user จริงเข้าไปแทน (บทถัดไปพูดเต็ม ๆ) คือสิ่งที่ทำให้ database query ของ function รันในฐานะ user นั้น ไม่ใช่ในฐานะ anon ด้วย --no-verify-jwt (เท่ากับ verify_jwt = false) request เดิมที่ไม่มี header Authorization เลยก็สำเร็จ
$ supabase functions serve --no-verify-jwt$ curl "$FUNCTIONS_URL/edge-hello"{"message":"Hello from the edge"}HTTP 200Secrets: .env ในเครื่อง, supabase secrets set ตอน deploy
หัวข้อที่มีชื่อว่า “Secrets: .env ในเครื่อง, supabase secrets set ตอน deploy”function มักต้องใช้ credential ที่ห้ามให้ client เห็นเด็ดขาด ในเครื่อง supabase functions serve --env-file <path> โหลดไฟล์ .env ธรรมดา อ่านในฟังก์ชันด้วย Deno.env.get แบบเดียวกันทั้งสองสภาพแวดล้อม
Deno.serve((_req) => { const secret = Deno.env.get('EDGE_DEMO_SECRET') ?? null return new Response(JSON.stringify({ secret }), { headers: { 'Content-Type': 'application/json' }, })})$ echo "EDGE_DEMO_SECRET=from-env-file" > .env.local$ supabase functions serve --env-file .env.local$ curl "$FUNCTIONS_URL/edge-secrets" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"secret":"from-env-file"}supabase secrets set คือคำสั่งเทียบเท่าสำหรับ deployed project — รันกับ local stack นี้ที่ไม่ได้ link cloud project ไว้ มันพังด้วย error ที่บอกตัวเองว่าเป็นคำสั่ง hosted-only ชัดเจน
$ supabase secrets set EDGE_DEMO_SECRET2=from-secrets-set{"_tag":"Error","error":{"code":"LegacyPlatformAuthRequiredError","message":"Access token not provided. Supply an access token by running `supabase login` or setting the SUPABASE_ACCESS_TOKEN environment variable."}}ไม่มี local emulation ของ supabase secrets set — .env/--env-file คือ เรื่องราวฝั่ง local ทั้งหมด supabase secrets set คุยกับ deployed project ที่ล็อกอินแล้วเท่านั้น
deno check เป็น pre-deploy gate
หัวข้อที่มีชื่อว่า “deno check เป็น pre-deploy gate”ทุก ts-edge fence จริงในคอร์สนี้ถูกรันผ่าน deno check ก่อนจะเชื่อถือได้อยู่แล้ว — คำสั่งเดียวกันที่ CI pipeline (หรือคุณเอง ก่อน supabase functions deploy) ควรรันกับ function directory
$ cd supabase/functions/edge-typecheck-demo && deno check index.tsCheck index.tsTS2345 [ERROR]: Argument of type 'string' is not assignable to parameter of type 'number'. const total = addToCount(5, "3") ~~~ at file:///.../edge-typecheck-demo/index.ts:6:31
error: Type checking failed.type error จริง (addToCount(5, "3") ส่ง string เข้าไปที่ประกาศไว้เป็น number) ถูกจับได้ก่อน deploy ไม่ใช่ไปเจอเป็น runtime 503 หลังจากนั้น — deno check คือ compiler ของ Deno เองที่ทำแบบเดียวกับที่ tsc --noEmit ทำให้โปรเจกต์ Node/browser แค่ scope อยู่ที่ module graph ของ function เดียว
flowchart LR
client["supabase.functions.invoke('edge-hello')"] -->|"HTTP request + Authorization header"| edge["Deno.serve function (jsr:/npm: imports resolved)"]
edge -->|"Response"| client อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”AI ถูกขอให้ “เรียก internal notifications service” จาก Edge Function แล้วได้โค้ดนี้มา
🐛 หา bug ในโค้ดนี้
import axios from 'axios'import { v4 as uuidv4 } from 'uuid'
Deno.serve(async (req) => {const { userId, message } = await req.json()
const response = await axios.post('https://notifications.internal/send', { id: uuidv4(), userId, message,})
return new Response(JSON.stringify(response.data), { headers: { 'Content-Type': 'application/json' },})})เฉลย
ทั้งสอง import เป็น bare specifier — import axios from 'axios' และ import { v4 as uuidv4 } from 'uuid' — ตรงกับ pattern ที่พังไม่ boot ด้วย BOOT_ERROR/“Relative import path … not prefixed with / or ./ or ../” จริงที่โชว์ไว้ก่อนหน้านี้ในบทนี้เลย เพราะ AI สร้างโค้ดนี้แบบเดียวกับที่จะทำให้ backend Node.js/Express ที่ bare specifier resolve ผ่าน node_modules ได้อัตโนมัติ ทุก request ที่เข้า function นี้จะ 503 ก่อนที่ handler code แม้แต่บรรทัดเดียวจะได้รัน วิธีแก้เป็นเรื่อง mechanical ไม่ต้อง redesign อะไร: import axios from 'npm:axios@1' และ import { v4 as uuidv4 } from 'npm:uuid@9' (หรือตัดทั้งคู่ออกไปเลย — crypto.randomUUID() เป็น Deno/Web-standard global ที่ไม่ต้อง import อะไร และ fetch() เปล่า ๆ แทน axios สำหรับ POST ธรรมดาได้โดยไม่ต้องมี dependency เลย)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”การเขียน Edge Function คือการเขียนโปรแกรม Deno เดี่ยว ๆ ที่มี module graph ของตัวเอง ไม่ใช่การเพิ่ม route ให้แอป Node.js ที่มีอยู่แล้ว ทุก import ถูก resolve ใหม่ต่อ function ตอน isolate boot ขึ้นมา นี่คือเหตุผลที่ bare specifier พังตอน boot (ก่อนที่ handler code ของคุณจะรันเลย) ไม่ใช่แค่พังที่บรรทัดที่ใช้มัน และทำไม deno.json ที่อยู่ข้าง index.ts ของ function หนึ่งจึงไม่มีผลกับ function อื่นเลย CORS และ verify_jwt อยู่ที่ boundary “ก่อนโค้ดคุณรัน” เดียวกันนี้ verify_jwt ถูกตรวจโดย runtime ก่อน handler ของคุณถูกเรียกด้วยซ้ำ (พิสูจน์ด้วย response body ที่มี structure ชัดเจน ไม่ใช่สิ่งที่โค้ดคุณสร้างเอง) ส่วน CORS header กลับกัน ไม่มีอะไรตรวจให้คุณเลย นี่คือเหตุผลที่ function ที่ไม่ตั้ง CORS header เลย “ทำงานได้” ทุกที่ที่ทดสอบด้วย curl หรือ browser same-origin แต่จะพังแบบเงียบ ๆ ทันทีที่ browser client จากคนละ origin จริง ๆ เรียกมา
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- specifier
jsr:/npm:ให้คุณใช้ npm/JSR ecosystem ทั้งหมดในฟังก์ชันได้ — แต่ต้องแลกกับการต้องจำ pin version เอง เพราะ specifier ที่ไม่ pin จะ resolve เป็น “อะไรก็ตามที่ล่าสุด ณ ตอนนั้น” โดยไม่มี lockfile แบบที่package-lock.jsonของโปรเจกต์ Node ให้คุณ deno.jsonimport map ต่อ function ทำให้ dependency ของ function หนึ่งอยู่ในขอบเขตของตัวเองล้วน ๆ — แต่ต้องแลกกับการซ้ำซ้อน: สอง function ที่ต้องใช้ package เดียวกัน ต้อง declare และ pin เองแยกกันทั้งคู่ ไม่มี rootpackage.jsonกลางให้พึ่งพาverify_jwt = true(default) reject request ที่ไม่ล็อกอินก่อนโค้ดคุณรัน ให้ฟรี ๆ — แต่ต้องแลกกับการใช้ไม่ได้กับอะไรที่ไม่ใช่ Supabase client ที่ล็อกอินแล้ว: Database Webhook หรือ third-party callback (บทถัดไป) ไม่มี Supabase JWT ให้ยื่น ต้องใช้verify_jwt = falseพร้อม authentication scheme ของตัวเองแทน
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “import npm package อะไรก็ได้แบบเดียวกับที่ทำในไฟล์ Node.js” ทำได้เฉพาะที่มี
npm:(หรือjsr:, URL, หรือ bare specifier ที่ map ผ่านdeno.json) ชัดเจนเท่านั้น — bare specifier คือ503ตอน boot ไม่ใช่เรื่องเซอร์ไพรส์ตอน runtime ที่เจอตอนใช้ครั้งแรก - “ถ้า curl ได้ 200 พร้อม JSON ที่ถูก แปลว่า CORS header ของฉันโอเคแล้ว” curl ไม่บังคับ CORS เลย — มีแค่ browser เท่านั้นที่บังคับ ด้วยการปฏิเสธไม่ให้ page JavaScript อ่าน response cross-origin เมื่อ header ไม่อนุญาต
200จาก curl พิสูจน์แค่ว่า function รันสำเร็จ ไม่ได้พิสูจน์อะไรเลยว่า browser client cross-origin จริงจะอ่านผลลัพธ์ได้ - “
verify_jwt = falseแปลว่าใครจะทำอะไรก็ได้” มันแค่เอา JWT check ที่มีอยู่แล้วออกไปก่อน handler จะรัน — function ที่verify_jwt = falseต้องรับผิดชอบ authentication และ authorization ของตัวเองเต็ม ๆ ตั้งแต่จุดนั้นไป (shared secret header, ตรวจ HMAC signature, หรือไม่มีอะไรเลยถ้าตั้งใจให้เป็น public จริง ๆ) - “
supabase secrets setใช้ในเครื่องได้เลยตอนทดสอบด้วย CLI” มันเป็นคำสั่งสำหรับ hosted project เท่านั้นที่ต้องsupabase loginก่อน สิ่งที่ใช้แทนในเครื่องเสมอคือ.env/--env-fileพิสูจน์ข้างบนด้วยLegacyPlatformAuthRequiredErrorจริง