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

การเขียนและ Invoke Function

Edge Function คือ Deno.serve handler ธรรมดาที่รับ HTTP request แล้วคืน response กลับไป เรียกใช้จาก client ด้วย supabase.functions.invoke() — แต่เพราะมันรันบน Deno การเขียนให้ถูกต้องแปลว่าต้อง import dependency และจัดการ cross-origin request แบบ Deno เอง

Deno.serve เป็น global ที่ Deno runtime มีให้ในตัว ไม่ต้อง import อะไรเพิ่ม คุณส่ง callback ที่รับ Request ที่เข้ามาแล้วคืน Response กลับไป เหมือน Fetch API ที่คุณคุ้นเคยจากฝั่ง browser อยู่แล้ว

supabase/functions/edge-hello/index.ts
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 ผ่านด้วยตัวเองก่อนจะรันด้วยซ้ำ — พูดถึงท้ายบทนี้

วิธีที่โค้ด Edge Function ที่ AI สร้างพังบ่อยที่สุด: import package แบบเดียวกับที่ทำในโปรเจกต์ Node.js หรือ Vite

// @expect-error worker fails to boot: bare specifiers are not resolvable in Deno
import { 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:30

Deno ไม่มี bare-specifier/node_modules-style resolution มาให้โดย default ทุก import ต้องเป็น relative path (./foo.ts), URL เต็ม ๆ หรือ package-registry specifier สองแบบนี้

  • jsr: — registry ของ Deno เอง (JSR) และเป็นที่ที่ @supabase/supabase-js ถูก publish ไว้สำหรับ Deno
  • npm: — resolve npm package ธรรมดาผ่าน npm compatibility layer ของ Deno

แก้แล้ว ทั้งสอง specifier ทำงานได้จริงกับ local stack นี้ใน function เดียวกัน

supabase/functions/edge-import-specifiers/index.ts
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"}

bare specifier ยังใช้ได้ถ้าคุณให้ mapping กับ Deno — deno.json ที่วางไว้ข้าง index.ts ของ function ทำหน้าที่เป็น import map ของ function นั้นเท่านั้น (แต่ละ function มี module graph ของตัวเอง ไม่มีอะไรรั่วไปยัง function ข้างเคียง)

supabase/functions/edge-import-map/deno.json
{
"imports": {
"supabase": "jsr:@supabase/supabase-js@2"
}
}
supabase/functions/edge-import-map/index.ts
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 แบบเดียวกับข้างบนทันที

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 จัดการให้ถูกต้อง

supabase/functions/edge-cors/index.ts
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 ถืออยู่

scripts/edge-cors-preflight.sh
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 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS,TRACE,CONNECT

request จริง ทั้งแบบที่ 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 OK
access-control-allow-origin: *
access-control-allow-headers: authorization, x-client-info, apikey, content-type
access-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 OK
Access-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 ของตัวเองเท่านั้นที่การันตีว่าทำงานได้

supabase-js มี method functions.invoke() แยกออกมาแทนที่จะใช้ fetch เปล่า ๆ เพราะทำงานที่เป็นประโยชน์ให้อัตโนมัติ: serialize body เป็น JSON และแนบ header Authorization ให้อัตโนมัติ เป็น access token ของ user ที่ล็อกอินอยู่ตอนนั้น หรือเป็น anon key ของโปรเจกต์ถ้าไม่มีใครล็อกอิน

src/invoke-edge-hello.ts
// @run
import { 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 ปลอม

ก่อนที่โค้ดของ function จะรันเลย Edge Runtime บังคับ Supabase JWT ที่ valid บนทุก request ได้ ควบคุมต่อ function ด้วย verify_jwt (default เป็น true — ปิดไว้สำหรับ function ที่ถูกเรียกโดยอะไรที่ไม่ใช่ client ที่ล็อกอินแล้ว เช่น webhook receiver ในบทถัดไป)

supabase/config.toml
[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 200

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

function มักต้องใช้ credential ที่ห้ามให้ client เห็นเด็ดขาด ในเครื่อง supabase functions serve --env-file <path> โหลดไฟล์ .env ธรรมดา อ่านในฟังก์ชันด้วย Deno.env.get แบบเดียวกันทั้งสองสภาพแวดล้อม

supabase/functions/edge-secrets/index.ts
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 ที่ล็อกอินแล้วเท่านั้น

ทุก ts-edge fence จริงในคอร์สนี้ถูกรันผ่าน deno check ก่อนจะเชื่อถือได้อยู่แล้ว — คำสั่งเดียวกันที่ CI pipeline (หรือคุณเอง ก่อน supabase functions deploy) ควรรันกับ function directory

$ cd supabase/functions/edge-typecheck-demo && deno check index.ts
Check index.ts
TS2345 [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
การ invoke จาก client ไปถึง Deno function ที่ serve อยู่ในเครื่อง

AI ถูกขอให้ “เรียก internal notifications service” จาก Edge Function แล้วได้โค้ดนี้มา

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

supabase/functions/notify-user/index.ts
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 เลย)

การเขียน 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 จริง ๆ เรียกมา

  • specifier jsr:/npm: ให้คุณใช้ npm/JSR ecosystem ทั้งหมดในฟังก์ชันได้ — แต่ต้องแลกกับการต้องจำ pin version เอง เพราะ specifier ที่ไม่ pin จะ resolve เป็น “อะไรก็ตามที่ล่าสุด ณ ตอนนั้น” โดยไม่มี lockfile แบบที่ package-lock.json ของโปรเจกต์ Node ให้คุณ
  • deno.json import map ต่อ function ทำให้ dependency ของ function หนึ่งอยู่ในขอบเขตของตัวเองล้วน ๆ — แต่ต้องแลกกับการซ้ำซ้อน: สอง function ที่ต้องใช้ package เดียวกัน ต้อง declare และ pin เองแยกกันทั้งคู่ ไม่มี root package.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 จริง
ทำไม import { createClient } from "@supabase/supabase-js" (ไม่มี prefix) พังตอนอยู่ใน Edge Function
deno.json ต่อ function ทำหน้าที่อะไร
ทำไม function ที่ไม่มี CORS header เลยยังคืน 200 ตอนทดสอบด้วย curl
request ไปยัง function ที่ verify_jwt = true (default) ไม่มี header Authorization เลย จะเกิดอะไรก่อน handler code จะรัน
สิ่งที่ใช้แทน supabase secrets set ในเครื่องสำหรับ deployed project คืออะไร
function ที่ AI สร้างเขียน import axios from 'axios' และ import { v4 as uuidv4 } from 'uuid' ใน supabase/functions/notify-user/index.ts จะเกิดอะไรขึ้น และวิธีแก้ที่น้อยที่สุดคือ?