Files
2026-07-13 12:31:02 +08:00

613 lines
17 KiB
Go

package apis
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/dop251/goja"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/security"
)
func executeEdgeFunction(app core.App, fn edgeFunction, record *core.Record) {
start := time.Now()
vm := goja.New()
vm.SetFieldNameMapper(goja.UncapFieldNameMapper())
var logs []string
outputMu := sync.Mutex{}
// Basic binds
vm.Set("$app", app)
if record != nil && record.Id != "" {
vm.Set("$record", record)
} else {
// Dummy record for testing
vm.Set("$record", map[string]any{
"id": "test_id",
"get": func(field string) string {
return "test_" + field
},
"collection": func() map[string]any {
return map[string]any{
"name": fn.TriggerCollection.String,
}
},
})
}
// Simple console.log implementation
vm.Set("console", map[string]any{
"log": func(args ...any) {
outputMu.Lock()
logs = append(logs, fmt.Sprint(args...))
outputMu.Unlock()
app.Logger().Info("Edge Function Log", "name", fn.Name, "message", args)
},
})
// Add fetch polyfill
vm.Set("fetch", func(url string, options map[string]any) (goja.Value, error) {
method := "GET"
var body io.Reader
headers := make(map[string]string)
if options != nil {
if m, ok := options["method"].(string); ok {
method = strings.ToUpper(m)
}
if h, ok := options["headers"].(map[string]any); ok {
for k, v := range h {
headers[k] = fmt.Sprintf("%v", v)
}
}
if b, ok := options["body"].(string); ok {
body = strings.NewReader(b)
}
}
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
// Parse JSON if possible
var jsonBody any
_ = json.Unmarshal(respBody, &jsonBody)
// Create response object
responseObj := map[string]any{
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
"status": resp.StatusCode,
"statusText": resp.Status,
"json": func() (goja.Value, error) {
p, resolve, _ := vm.NewPromise()
resolve(vm.ToValue(jsonBody))
return vm.ToValue(p), nil
},
"text": func() (goja.Value, error) {
p, resolve, _ := vm.NewPromise()
resolve(vm.ToValue(string(respBody)))
return vm.ToValue(p), nil
},
}
// Return a resolved promise
promise, resolve, _ := vm.NewPromise()
resolve(vm.ToValue(responseObj))
return vm.ToValue(promise), nil
})
// Execute the body
_, err := vm.RunString(fn.Body.String)
duration := time.Since(start).Milliseconds()
output := strings.Join(logs, "\n")
if err != nil {
app.Logger().Error("Edge Function Execution Failed", "name", fn.Name, "error", err.Error())
logEdgeFunctionExecution(app, fn.Id, "error", duration, output, err.Error())
} else {
app.Logger().Info("Edge Function Executed Successfully", "name", fn.Name)
logEdgeFunctionExecution(app, fn.Id, "success", duration, output, "")
}
}
type edgeFunction struct {
Id string `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Body sql.NullString `db:"body" json:"body"`
TriggerType sql.NullString `db:"trigger_type" json:"trigger_type"`
TriggerConfig sql.NullString `db:"trigger_config" json:"trigger_config"`
Active bool `db:"active" json:"active"`
TriggerCollection sql.NullString `db:"trigger_collection" json:"trigger_collection"`
TriggerEvent sql.NullString `db:"trigger_event" json:"trigger_event"`
Created sql.NullTime `db:"created" json:"created"`
Updated sql.NullTime `db:"updated" json:"updated"`
}
func logEdgeFunctionExecution(app core.App, fnId string, status string, duration int64, output string, errStr string) {
id := security.PseudorandomStringWithAlphabet(15, "abcdefghijklmnopqrstuvwxyz0123456789")
_, err := app.NonconcurrentDB().Insert("_edge_function_logs", dbx.Params{
"id": id,
"function_id": fnId,
"status": status,
"duration": duration,
"output": output,
"error": errStr,
}).Execute()
if err != nil {
app.Logger().Error("Failed to save edge function log", "error", err.Error())
}
}
func reloadCronJobs(app core.App) {
if app.Cron() == nil {
app.Logger().Warn("Cron is not initialized, skipping reload")
return
}
app.Logger().Info("Reloading edge function cron jobs")
// Remove all existing edge function cron jobs
for _, job := range app.Cron().Jobs() {
if strings.HasPrefix(job.Id(), "edge_") {
app.Logger().Info("Removing cron job", "id", job.Id())
app.Cron().Remove(job.Id())
}
}
var functions []edgeFunction
err := app.DB().
Select("*").
From("_edge_functions").
Where(dbx.HashExp{
"active": true,
"trigger_type": "cron",
}).
All(&functions)
if err != nil {
return
}
for _, fn := range functions {
cronExpr := fn.TriggerConfig.String
if cronExpr == "" {
continue
}
fnCopy := fn // capture for closure
jobName := "edge_" + fn.Id
app.Cron().Add(jobName, cronExpr, func() {
executeEdgeFunction(app, fnCopy, nil)
})
}
}
// bindEdgeFunctionsApi registers the Edge Functions API routes.
func bindEdgeFunctionsApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
subGroup := rg.Group("/functions").Bind(RequireSuperuserAuth())
// Ensure the table exists on startup
ensureEdgeFunctionsTable(app)
// Load cron jobs and register webhook on startup
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
// Register HTTP Trigger Route (Public)
// We register it here to avoid conflicts with the static file server
e.Router.POST("/edge-hook/{name}", func(e *core.RequestEvent) error {
name := e.Request.PathValue("name")
var fn edgeFunction
err := e.App.DB().
Select("*").
From("_edge_functions").
Where(dbx.HashExp{
"active": true,
"trigger_type": "http",
"name": name,
}).
One(&fn)
if err != nil {
return e.NotFoundError("Function not found or inactive.", nil)
}
// Execute function
go executeEdgeFunction(e.App, fn, nil)
return e.JSON(http.StatusOK, map[string]any{
"message": "Function triggered successfully",
})
})
reloadCronJobs(e.App)
return e.Next()
})
// Register hooks to trigger functions
app.OnRecordAfterCreateSuccess().BindFunc(func(e *core.RecordEvent) error {
collectionName := e.Record.Collection().Name
var functions []edgeFunction
err := e.App.DB().
Select("*").
From("_edge_functions").
Where(dbx.HashExp{
"active": true,
"trigger_type": "record",
"trigger_collection": collectionName,
"trigger_event": "create",
}).
All(&functions)
if err != nil {
return e.Next()
}
for _, fn := range functions {
go executeEdgeFunction(e.App, fn, e.Record)
}
return e.Next()
})
app.OnRecordAfterUpdateSuccess().BindFunc(func(e *core.RecordEvent) error {
var functions []edgeFunction
err := e.App.DB().
Select("*").
From("_edge_functions").
Where(dbx.HashExp{
"active": true,
"trigger_type": "record",
"trigger_collection": e.Record.Collection().Name,
"trigger_event": "update",
}).
All(&functions)
if err == nil {
for _, fn := range functions {
go executeEdgeFunction(e.App, fn, e.Record)
}
}
return e.Next()
})
app.OnRecordAfterDeleteSuccess().BindFunc(func(e *core.RecordEvent) error {
var functions []edgeFunction
err := e.App.DB().
Select("*").
From("_edge_functions").
Where(dbx.HashExp{
"active": true,
"trigger_type": "record",
"trigger_collection": e.Record.Collection().Name,
"trigger_event": "delete",
}).
All(&functions)
if err == nil {
for _, fn := range functions {
go executeEdgeFunction(e.App, fn, e.Record)
}
}
return e.Next()
})
// Logs API
subGroup.GET("/{id}/logs", func(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
var logs []map[string]any
err := e.App.DB().
Select("*").
From("_edge_function_logs").
Where(dbx.HashExp{"function_id": id}).
OrderBy("created DESC").
Limit(50).
All(&logs)
if err != nil {
return e.BadRequestError("Failed to load logs", err)
}
return e.JSON(http.StatusOK, logs)
})
subGroup.GET("", func(e *core.RequestEvent) error {
var functions []edgeFunction
e.App.Logger().Info("Executing GET /api/functions")
// Use app.DB() which is the standard way to access the data database
err := e.App.DB().
Select("*").
From("_edge_functions").
OrderBy("created DESC").
All(&functions)
if err != nil {
e.App.Logger().Error("Failed to load edge functions", "error", err.Error())
return e.BadRequestError("Failed to load edge functions: "+err.Error(), nil)
}
e.App.Logger().Info("Loaded edge functions from DB", "count", len(functions))
for _, f := range functions {
e.App.Logger().Info("Function in list", "id", f.Id, "name", f.Name)
}
// Convert to response format with string dates
result := make([]map[string]any, len(functions))
for i, f := range functions {
created := ""
if f.Created.Valid {
created = f.Created.Time.Format("2006-01-02 15:04:05.000Z")
}
updated := ""
if f.Updated.Valid {
updated = f.Updated.Time.Format("2006-01-02 15:04:05.000Z")
}
// Debug log each function ID and Name
e.App.Logger().Debug("Function row", "id", f.Id, "name", f.Name)
result[i] = map[string]any{
"id": f.Id,
"name": f.Name,
"body": f.Body.String,
"trigger_type": f.TriggerType.String,
"trigger_config": f.TriggerConfig.String,
"active": f.Active,
"trigger_collection": f.TriggerCollection.String,
"trigger_event": f.TriggerEvent.String,
"created": created,
"updated": updated,
}
}
return e.JSON(http.StatusOK, result)
})
subGroup.POST("", func(e *core.RequestEvent) error {
data := struct {
Name string `json:"name" form:"name"`
Body string `json:"body" form:"body"`
TriggerType string `json:"trigger_type" form:"trigger_type"`
TriggerConfig string `json:"trigger_config" form:"trigger_config"`
Active bool `json:"active" form:"active"`
TriggerCollection string `json:"trigger_collection" form:"trigger_collection"`
TriggerEvent string `json:"trigger_event" form:"trigger_event"`
}{}
if err := e.BindBody(&data); err != nil {
return e.BadRequestError("Failed to parse request body.", err)
}
if data.Name == "" {
return e.BadRequestError("Function name is required.", nil)
}
e.App.Logger().Info("Creating function",
"name", data.Name,
"trigger_col", data.TriggerCollection,
"trigger_event", data.TriggerEvent)
id := security.PseudorandomStringWithAlphabet(15, "abcdefghijklmnopqrstuvwxyz0123456789")
// Insert the function
_, err := e.App.NonconcurrentDB().Insert("_edge_functions", dbx.Params{
"id": id,
"name": data.Name,
"body": data.Body,
"trigger_type": data.TriggerType,
"trigger_config": data.TriggerConfig,
"active": data.Active,
"trigger_collection": data.TriggerCollection,
"trigger_event": data.TriggerEvent,
}).Execute()
if err != nil {
return e.BadRequestError("Failed to create function: "+err.Error(), nil)
}
if data.TriggerType == "cron" {
reloadCronJobs(e.App)
}
// Return the created object
return e.JSON(http.StatusOK, map[string]any{
"id": id,
"name": data.Name,
"body": data.Body,
"trigger_type": data.TriggerType,
"trigger_config": data.TriggerConfig,
"active": data.Active,
"trigger_collection": data.TriggerCollection,
"trigger_event": data.TriggerEvent,
})
})
subGroup.GET("/{id}", func(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
var f edgeFunction
err := e.App.ConcurrentDB().
Select("*").
From("_edge_functions").
Where(dbx.HashExp{"id": id}).
One(&f)
if err != nil {
return e.NotFoundError("Function not found.", nil)
}
created := ""
if f.Created.Valid {
created = f.Created.Time.Format("2006-01-02 15:04:05.000Z")
}
updated := ""
if f.Updated.Valid {
updated = f.Updated.Time.Format("2006-01-02 15:04:05.000Z")
}
return e.JSON(http.StatusOK, map[string]any{
"id": f.Id,
"name": f.Name,
"body": f.Body.String,
"trigger_collection": f.TriggerCollection.String,
"trigger_event": f.TriggerEvent.String,
"created": created,
"updated": updated,
})
})
subGroup.POST("/{id}/test", func(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
var f edgeFunction
err := e.App.ConcurrentDB().
Select("*").
From("_edge_functions").
Where(dbx.HashExp{"id": id}).
One(&f)
if err != nil {
return e.NotFoundError("Function not found.", nil)
}
// Execute with a dummy record
go executeEdgeFunction(e.App, f, &core.Record{})
return e.JSON(http.StatusOK, map[string]any{
"message": "Test execution started",
})
})
subGroup.PATCH("/{id}", func(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
e.App.Logger().Info("PATCH function request", "id", id)
data := struct {
Name string `json:"name" form:"name"`
Body string `json:"body" form:"body"`
TriggerType string `json:"trigger_type" form:"trigger_type"`
TriggerConfig string `json:"trigger_config" form:"trigger_config"`
Active bool `json:"active" form:"active"`
TriggerCollection string `json:"trigger_collection" form:"trigger_collection"`
TriggerEvent string `json:"trigger_event" form:"trigger_event"`
}{}
if err := e.BindBody(&data); err != nil {
e.App.Logger().Error("Failed to bind body", "error", err.Error())
return e.BadRequestError("Failed to parse request body.", err)
}
e.App.Logger().Info("Updating function",
"name", data.Name,
"body_len", len(data.Body),
"trigger_col", data.TriggerCollection,
"trigger_event", data.TriggerEvent)
_, err := e.App.NonconcurrentDB().Update("_edge_functions", dbx.Params{
"name": data.Name,
"body": data.Body,
"trigger_type": data.TriggerType,
"trigger_config": data.TriggerConfig,
"active": data.Active,
"trigger_collection": data.TriggerCollection,
"trigger_event": data.TriggerEvent,
}, dbx.HashExp{"id": id}).Execute()
if err != nil {
e.App.Logger().Error("Failed to update function in DB", "error", err.Error())
return e.BadRequestError("Failed to update function: "+err.Error(), nil)
}
reloadCronJobs(e.App)
return e.JSON(http.StatusOK, map[string]any{
"id": id,
"name": data.Name,
"body": data.Body,
"trigger_type": data.TriggerType,
"trigger_config": data.TriggerConfig,
"active": data.Active,
"trigger_collection": data.TriggerCollection,
"trigger_event": data.TriggerEvent,
})
})
subGroup.DELETE("/{id}", func(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
_, err := e.App.NonconcurrentDB().Delete("_edge_functions", dbx.HashExp{"id": id}).Execute()
if err != nil {
return e.BadRequestError("Failed to delete function: "+err.Error(), nil)
}
reloadCronJobs(e.App)
return e.NoContent(http.StatusNoContent)
})
}
// ensureEdgeFunctionsTable creates the _edge_functions table if it doesn't exist (PostgreSQL compatible)
func ensureEdgeFunctionsTable(app core.App) {
_, _ = app.NonconcurrentDB().NewQuery(`
CREATE TABLE IF NOT EXISTS _edge_functions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
body TEXT,
trigger_type TEXT DEFAULT 'record',
trigger_config TEXT,
active BOOLEAN DEFAULT TRUE,
trigger_collection TEXT,
trigger_event TEXT,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`).Execute()
// Add columns if they don't exist (for existing tables)
_, _ = app.NonconcurrentDB().NewQuery(`ALTER TABLE _edge_functions ADD COLUMN IF NOT EXISTS trigger_type TEXT DEFAULT 'record'`).Execute()
_, _ = app.NonconcurrentDB().NewQuery(`ALTER TABLE _edge_functions ADD COLUMN IF NOT EXISTS trigger_config TEXT`).Execute()
_, _ = app.NonconcurrentDB().NewQuery(`ALTER TABLE _edge_functions ADD COLUMN IF NOT EXISTS active BOOLEAN DEFAULT TRUE`).Execute()
_, _ = app.NonconcurrentDB().NewQuery(`ALTER TABLE _edge_functions ADD COLUMN IF NOT EXISTS trigger_collection TEXT`).Execute()
_, _ = app.NonconcurrentDB().NewQuery(`ALTER TABLE _edge_functions ADD COLUMN IF NOT EXISTS trigger_event TEXT`).Execute()
// Create logs table
_, _ = app.NonconcurrentDB().NewQuery(`
CREATE TABLE IF NOT EXISTS _edge_function_logs (
id TEXT PRIMARY KEY,
function_id TEXT NOT NULL,
status TEXT,
duration INTEGER,
output TEXT,
error TEXT,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`).Execute()
}