diff --git a/apps/edge-workers/Makefile b/apps/edge-workers/Makefile new file mode 100644 index 0000000..b2380f4 --- /dev/null +++ b/apps/edge-workers/Makefile @@ -0,0 +1,22 @@ +.PHONY: build run test clean deploy-example + +BIN := edge-workers + +build: + go build -o $(BIN) . + +run: build + ./$(BIN) + +test: + go test ./... + +clean: + rm -f $(BIN) + +# Deploy a worker against a running instance (control plane on :8090). +# Usage: make deploy-example +deploy-example: + curl -s -X POST http://localhost:8090/workers \ + -H 'Content-Type: application/json' \ + -d '{"name":"hello-php","route":"/hello","runtime":"php","script":"'"$$(pwd)"'/workers/hello.php","env":{"GREETING":"Xin chào"}}' diff --git a/apps/edge-workers/README.md b/apps/edge-workers/README.md new file mode 100644 index 0000000..e999019 --- /dev/null +++ b/apps/edge-workers/README.md @@ -0,0 +1,132 @@ +# edge-workers + +A **Cloudflare Workers-style serverless edge runtime** built on the Fluxor +verticle model. Deploy small scripts ("workers") bound to routes; each request +runs the script in a short-lived, isolated subprocess and returns its response. + +## Architecture / flow + +``` + HTTP / IO CPU layer engine logic + (net/http) (event loop · kqueue · verticle) (subprocess) (script) +┌──────────┐ req ┌───────────────────────────┐ ┌────────────┐ ┌─────────┐ +│ gateway │ ──────▶ │ EdgeVerticle │ │ PHP engine │ │ stdin → │ +│ :8080 │ │ 1. RunOnEventLoop: │ ─▶ │ Node engine│ ─▶ │ logic │ +│ │ ◀────── │ match route (<20µs) │ │ │ │ → stdout│ +└──────────┘ resp │ 2. SubmitBlockingFunc: │ ◀─ └────────────┘ ◀─ └─────────┘ + │ run engine on pool │ + └───────────────────────────┘ +``` + +- **HTTP / IO** — the gateway only parses the request and hands off; it does no work. +- **CPU layer** — the verticle's **event loop** does the cheap, sequential route + match (race-free, `<20µs`), then offloads the blocking script execution to the + **worker pool** via `SubmitBlockingFunc`, so the event loop never blocks. This is + the standard Fluxor / Vert.x split. +- **Engines** — lightweight language runtimes invoked as subprocesses: **PHP** and + **Node**. A JVM-class engine is deliberately *not* provided — too heavy for a + per-request edge model. +- **Scripts handle logic only — no HTTP.** A worker never opens a socket or starts + a server. It reads the request as JSON on **stdin**, computes, and writes the + response as JSON on **stdout**. All networking lives in the Go gateway. + +## The worker contract + +Request delivered on **stdin**: + +```json +{ "method": "GET", "path": "/hello", "query": "name=Khang", + "headers": {"...":"..."}, "body": "", "worker": "hello-php" } +``` + +Response written to **stdout** (raw text is also accepted → `200 text/plain`): + +```json +{ "status": 200, "headers": {"Content-Type": "application/json"}, "body": "..." } +``` + +Per-worker bindings (`env`) are injected as process environment variables. + +### KV bindings (logic-only, no network) + +A worker declares KV bindings (`bindingName → namespace`). On each invocation the +runtime snapshots the bound namespaces into the request, and applies any mutations +the worker returns — so scripts get durable cross-request state without opening a +socket: + +```jsonc +// request adds: "kv": { "COUNTERS": { "visits": "41" } } +// response adds: "kv": [ { "binding": "COUNTERS", "op": "put", "key": "visits", "value": "42", "ttl": 0 } ] +// ("op": "delete" removes a key; "ttl" is seconds, 0 = no expiry) +``` + +### Cron triggers + +A worker with a `cron` field (standard 5-field expression) is scheduled via Fluxor's +scheduler. On each tick the runtime synthesises a request with `"trigger":"scheduled"` +(and `"method":"SCHEDULED"`) and runs the worker through the same event-loop → +worker-pool path. The same script can serve both HTTP and scheduled triggers by +inspecting `request.trigger`. + +## Run + +```bash +make run # builds and starts the runtime +``` + +- Data plane (gateway): `http://localhost:8080/` +- Control plane (admin): `http://localhost:8090/workers` + +Four example workers are seeded on start: + +| Worker | Route | Runtime | Feature | +|------------------|--------------|---------|---------------------------------| +| `hello.php` | `/hello` | PHP | env binding (`GREETING`) | +| `echo.js` | `/echo` | Node | request echo | +| `counter.php` | `/counter` | PHP | **KV binding** (visit counter) | +| `heartbeat.js` | `/heartbeat` | Node | **cron trigger** + KV state | + +```bash +curl 'http://localhost:8080/hello?name=Khang' # PHP worker (env binding) +curl -X POST http://localhost:8080/echo -d '{"hi":1}' # Node worker +curl http://localhost:8080/counter # KV: increments each hit +curl http://localhost:8080/heartbeat # reports cron tick count +``` + +## Management API (deploy at runtime) + +```bash +# List +curl http://localhost:8090/workers + +# Deploy (or replace) a worker — env, KV bindings, and cron are all optional +curl -X POST http://localhost:8090/workers -H 'Content-Type: application/json' -d '{ + "name": "hello-php", "route": "/hello", "runtime": "php", + "script": "/abs/path/workers/hello.php", + "env": {"GREETING": "Hi"}, + "kv": {"COUNTERS": "counters"}, + "cron": "*/5 * * * *" +}' + +# Delete +curl -X DELETE http://localhost:8090/workers/hello-php +``` + +## Layout + +| File | Responsibility | +|---------------|-----------------------------------------------------------| +| `engine.go` | Engine interface + PHP/Node subprocess engines | +| `worker.go` | Worker model, stdin/stdout JSON contract, route registry | +| `kv.go` | In-memory KV store (namespaces + TTL) + write-back ops | +| `verticle.go` | `EdgeVerticle` — event-loop dispatch + worker-pool offload | +| `cron.go` | `CronManager` — cron triggers via `pkg/scheduler` | +| `server.go` | HTTP gateway (data plane) + management API (control plane) | +| `main.go` | Bootstrap, engine detection, example seeding | +| `workers/` | Example scripts (`hello.php`, `echo.js`, `counter.php`, `heartbeat.js`) | + +## Adding a worker + +1. Drop a script in `workers/` that reads stdin / writes stdout per the contract. +2. `POST /workers` with its `route`, `runtime`, and absolute `script` path. +3. Requests matching the route now execute it. No restart needed. diff --git a/apps/edge-workers/config.json b/apps/edge-workers/config.json new file mode 100644 index 0000000..4f30b91 --- /dev/null +++ b/apps/edge-workers/config.json @@ -0,0 +1,8 @@ +{ + "name": "edge-workers", + "description": "Cloudflare Workers-style serverless edge runtime on Fluxor", + "edgeAddr": ":8080", + "adminAddr": ":8090", + "engineTimeout": "10s", + "engines": ["php", "node"] +} diff --git a/apps/edge-workers/cron.go b/apps/edge-workers/cron.go new file mode 100644 index 0000000..bba800c --- /dev/null +++ b/apps/edge-workers/cron.go @@ -0,0 +1,134 @@ +// Copyright (c) 2024-2028 Fluxor Framework +// All rights reserved. +// +// Cron triggers — schedule a worker to run on a cron expression, the edge-workers +// analogue of Cloudflare Workers Cron Triggers. +// +// A scheduled invocation reuses the exact same execution path as an HTTP request: +// the runtime synthesises a WorkerRequest with Trigger="scheduled" and feeds it to +// the verticle (event loop → worker pool → engine → script). The script handles it +// as ordinary logic — it inspects request.trigger and acts accordingly. + +package main + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/fluxorio/fluxor/pkg/core" + "github.com/fluxorio/fluxor/pkg/core/concurrency" + "github.com/fluxorio/fluxor/pkg/scheduler" +) + +// CronManager binds workers' cron expressions to the Fluxor scheduler and fires +// scheduled invocations through the verticle. +type CronManager struct { + sched scheduler.Scheduler + verticle *EdgeVerticle + registry *Registry + logger core.Logger + + mu sync.Mutex + ids map[string]string // worker name -> scheduler task id +} + +// NewCronManager creates a cron manager bound to the given context lifetime. +func NewCronManager(ctx context.Context, v *EdgeVerticle, r *Registry) *CronManager { + return &CronManager{ + sched: scheduler.NewScheduler(ctx), + verticle: v, + registry: r, + logger: core.NewDefaultLogger(), + ids: make(map[string]string), + } +} + +// Start begins firing scheduled tasks. +func (c *CronManager) Start(ctx context.Context) error { + return c.sched.Start(ctx) +} + +// Stop halts the scheduler. +func (c *CronManager) Stop() error { + return c.sched.Stop() +} + +// Register schedules a worker if it declares a cron expression. Calling it again +// for the same worker first cancels the previous schedule (deploy = replace). +// A worker without a Cron field is a no-op. +func (c *CronManager) Register(w *Worker) error { + if strings.TrimSpace(w.Cron) == "" { + return nil + } + c.Unregister(w.Name) + + name := w.Name + task := concurrency.NewNamedTask("cron:"+name, func(ctx context.Context) error { + return c.fire(ctx, name) + }) + + spec, err := safeCronSpec(w.Cron) + if err != nil { + return fmt.Errorf("worker %q: %w", name, err) + } + id, err := c.sched.Schedule(task, spec) + if err != nil { + return fmt.Errorf("schedule worker %q with cron %q: %w", name, w.Cron, err) + } + + c.mu.Lock() + c.ids[name] = id + c.mu.Unlock() + c.logger.Info(fmt.Sprintf("cron registered: %q → %q", name, w.Cron)) + return nil +} + +// Unregister cancels a worker's schedule if present. +func (c *CronManager) Unregister(name string) { + c.mu.Lock() + id, ok := c.ids[name] + delete(c.ids, name) + c.mu.Unlock() + if ok { + _ = c.sched.Unschedule(id) + } +} + +// safeCronSpec builds a cron schedule, converting scheduler.Cron's fail-fast +// panic on a malformed expression into an ordinary error so a bad deploy request +// yields a clean 400 instead of aborting the connection. +func safeCronSpec(expr string) (spec scheduler.ScheduleSpec, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("invalid cron expression %q: %v", expr, r) + } + }() + return scheduler.Cron(expr), nil +} + +// fire looks the worker up fresh (it may have been replaced) and dispatches a +// synthetic scheduled request through the verticle. +func (c *CronManager) fire(ctx context.Context, name string) error { + worker := c.registry.Get(name) + if worker == nil { + c.Unregister(name) // worker was deleted — stop firing + return nil + } + + runCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + req := WorkerRequest{ + Method: "SCHEDULED", + Path: worker.Route, + Trigger: "scheduled", + Cron: worker.Cron, + Headers: map[string]string{}, + } + resp := c.verticle.RunWorker(runCtx, worker, req) + c.logger.Info(fmt.Sprintf("cron fired: %q (status %d)", name, resp.Status)) + return nil +} diff --git a/apps/edge-workers/engine.go b/apps/edge-workers/engine.go new file mode 100644 index 0000000..27cd202 --- /dev/null +++ b/apps/edge-workers/engine.go @@ -0,0 +1,126 @@ +// Copyright (c) 2024-2028 Fluxor Framework +// All rights reserved. +// +// Edge Workers — a Cloudflare Workers-style serverless edge runtime for Fluxor. +// +// Execution flow (matches the Fluxor verticle model): +// +// HTTP / IO ──▶ CPU layer (event loop · kqueue · verticle) ──▶ script engine ──▶ script +// net/http EdgeVerticle.dispatch on its own event loop PHP / Node user code +// (gateway) offloads the blocking subprocess to a subprocess +// worker pool via SubmitBlockingFunc +// +// Engines are deliberately *lightweight* language runtimes invoked as short-lived +// subprocesses: PHP (php-cli) and Node (node). A JVM-style engine is intentionally +// NOT provided — it is too heavy for an edge / per-request execution model. +// +// This generalises pkg/script.PHPExecutor (the existing single-language executor) +// to a multi-runtime engine registry with a uniform stdin/stdout JSON contract. + +package main + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "time" +) + +// Runtime identifies which language engine executes a worker script. +type Runtime string + +const ( + RuntimePHP Runtime = "php" + RuntimeNode Runtime = "node" +) + +// Engine executes a worker script for a single request and returns its raw stdout. +// +// The request is delivered to the script as a JSON document on stdin; the script +// is expected to emit a JSON WorkerResponse on stdout (see worker.go). Engines are +// stateless and safe for concurrent use — each Run spawns an isolated subprocess, +// which is the unit of isolation in this runtime (an "isolate" in Workers terms). +type Engine interface { + // Runtime returns the runtime this engine implements. + Runtime() Runtime + // Available reports whether the underlying interpreter is installed. + Available() bool + // Run executes scriptPath with reqJSON piped to stdin and returns stdout. + Run(ctx context.Context, scriptPath string, reqJSON []byte, env []string) ([]byte, error) +} + +// processEngine is the shared subprocess implementation backing every engine. +// It pipes the request to stdin, captures stdout/stderr, and enforces a hard +// timeout so a runaway script can never pin a worker-pool goroutine. +type processEngine struct { + runtime Runtime + bin string // interpreter binary, e.g. "php" or "node" + argv []string // leading args before the script path + timeout time.Duration // hard per-request CPU/wall limit +} + +func (e *processEngine) Runtime() Runtime { return e.runtime } + +func (e *processEngine) Available() bool { + _, err := exec.LookPath(e.bin) + return err == nil +} + +func (e *processEngine) Run(ctx context.Context, scriptPath string, reqJSON []byte, env []string) ([]byte, error) { + // Bound execution time independently of the caller's context so a single + // slow script cannot hold a worker-pool slot open indefinitely. + runCtx := ctx + if e.timeout > 0 { + var cancel context.CancelFunc + runCtx, cancel = context.WithTimeout(ctx, e.timeout) + defer cancel() + } + + args := append(append([]string{}, e.argv...), scriptPath) + cmd := exec.CommandContext(runCtx, e.bin, args...) + cmd.Stdin = bytes.NewReader(reqJSON) + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if runCtx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("%s engine: script timed out after %s", e.runtime, e.timeout) + } + if err != nil { + return nil, fmt.Errorf("%s engine: %w (stderr: %s)", e.runtime, err, truncate(stderr.String(), 512)) + } + return stdout.Bytes(), nil +} + +// NewPHPEngine builds the PHP engine. Worker scripts run as `php `. +func NewPHPEngine(timeout time.Duration) Engine { + return &processEngine{runtime: RuntimePHP, bin: "php", timeout: timeout} +} + +// NewNodeEngine builds the Node engine. Worker scripts run as `node `. +func NewNodeEngine(timeout time.Duration) Engine { + return &processEngine{runtime: RuntimeNode, bin: "node", timeout: timeout} +} + +// EngineSet is the registry of available language engines, keyed by runtime. +type EngineSet map[Runtime]Engine + +// DefaultEngines returns the lightweight engines this runtime supports. +// JVM / heavy runtimes are intentionally omitted from the edge tier. +func DefaultEngines(timeout time.Duration) EngineSet { + return EngineSet{ + RuntimePHP: NewPHPEngine(timeout), + RuntimeNode: NewNodeEngine(timeout), + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/apps/edge-workers/kv.go b/apps/edge-workers/kv.go new file mode 100644 index 0000000..3522f76 --- /dev/null +++ b/apps/edge-workers/kv.go @@ -0,0 +1,150 @@ +// Copyright (c) 2024-2028 Fluxor Framework +// All rights reserved. +// +// KV binding — an in-memory, TTL-aware key/value store, the edge-workers analogue +// of Cloudflare Workers KV. +// +// Because worker scripts are pure logic (no HTTP, no network — they only read +// stdin and write stdout), KV is exposed through the same stdin/stdout contract: +// +// - on dispatch, the runtime snapshots each KV namespace bound to the worker +// into the request (`request.kv[binding] = {key: value}`); +// - the script reads/computes against that snapshot and returns mutations +// (`response.kv = [{binding, op, key, value, ttl}]`); +// - the runtime applies those mutations to the store after the script returns. +// +// This keeps scripts side-effect-free at the process boundary while giving them +// durable, cross-request state. + +package main + +import ( + "sync" + "time" +) + +type kvEntry struct { + value string + expiresAt time.Time // zero = no expiry +} + +func (e kvEntry) expired(now time.Time) bool { + return !e.expiresAt.IsZero() && now.After(e.expiresAt) +} + +// KVNamespace is a single key space (like a Workers KV namespace). +type KVNamespace struct { + mu sync.RWMutex + data map[string]kvEntry +} + +func newKVNamespace() *KVNamespace { + return &KVNamespace{data: make(map[string]kvEntry)} +} + +// Get returns the live value for key (respecting TTL). +func (ns *KVNamespace) Get(key string) (string, bool) { + ns.mu.RLock() + e, ok := ns.data[key] + ns.mu.RUnlock() + if !ok || e.expired(time.Now()) { + return "", false + } + return e.value, true +} + +// Put stores key=value with an optional TTL (0 = no expiry). +func (ns *KVNamespace) Put(key, value string, ttl time.Duration) { + var exp time.Time + if ttl > 0 { + exp = time.Now().Add(ttl) + } + ns.mu.Lock() + ns.data[key] = kvEntry{value: value, expiresAt: exp} + ns.mu.Unlock() +} + +// Delete removes key. +func (ns *KVNamespace) Delete(key string) { + ns.mu.Lock() + delete(ns.data, key) + ns.mu.Unlock() +} + +// Snapshot returns a copy of all live (non-expired) entries. +func (ns *KVNamespace) Snapshot() map[string]string { + now := time.Now() + ns.mu.RLock() + defer ns.mu.RUnlock() + out := make(map[string]string, len(ns.data)) + for k, e := range ns.data { + if !e.expired(now) { + out[k] = e.value + } + } + return out +} + +// KVStore owns all namespaces and hands them out lazily by name. +type KVStore struct { + mu sync.Mutex + namespaces map[string]*KVNamespace +} + +// NewKVStore creates an empty KV store. +func NewKVStore() *KVStore { + return &KVStore{namespaces: make(map[string]*KVNamespace)} +} + +// Namespace returns the named namespace, creating it on first use. +func (s *KVStore) Namespace(name string) *KVNamespace { + s.mu.Lock() + defer s.mu.Unlock() + ns, ok := s.namespaces[name] + if !ok { + ns = newKVNamespace() + s.namespaces[name] = ns + } + return ns +} + +// KVOp is a single mutation a worker requests against a bound namespace. +// It is part of the worker response contract. +type KVOp struct { + Binding string `json:"binding"` // binding name declared on the worker + Op string `json:"op"` // "put" | "delete" + Key string `json:"key"` // target key + Value string `json:"value"` // value for "put" + TTL int `json:"ttl,omitempty"` // seconds; 0 = no expiry +} + +// snapshotBindings returns {binding: {key: value}} for every namespace bound to +// the worker — the view of KV the script sees on stdin. +func (s *KVStore) snapshotBindings(bindings map[string]string) map[string]map[string]string { + if len(bindings) == 0 { + return nil + } + out := make(map[string]map[string]string, len(bindings)) + for binding, nsName := range bindings { + out[binding] = s.Namespace(nsName).Snapshot() + } + return out +} + +// applyOps writes the mutations a worker returned back into the store, resolving +// each op's binding name to its namespace. +func (s *KVStore) applyOps(bindings map[string]string, ops []KVOp) { + for _, op := range ops { + nsName, ok := bindings[op.Binding] + if !ok { + continue // worker referenced a binding it doesn't declare — ignore + } + ns := s.Namespace(nsName) + switch op.Op { + case "put": + ns.Put(op.Key, op.Value, time.Duration(op.TTL)*time.Second) + case "delete": + ns.Delete(op.Key) + } + } +} diff --git a/apps/edge-workers/main.go b/apps/edge-workers/main.go new file mode 100644 index 0000000..fd9bd30 --- /dev/null +++ b/apps/edge-workers/main.go @@ -0,0 +1,149 @@ +// Copyright (c) 2024-2028 Fluxor Framework +// All rights reserved. +// +// edge-workers — a Cloudflare Workers-style serverless edge runtime built on +// the Fluxor verticle model. +// +// HTTP/IO ─▶ CPU (event loop · kqueue · verticle) ─▶ engine (PHP | Node) ─▶ script +// +// The data plane (edge gateway, :8080) routes each request to a deployed worker +// script. The control plane (management API, :8090) deploys / lists / deletes +// workers at runtime. Lightweight engines only — no JVM at the edge. + +package main + +import ( + "context" + "log" + "os" + "path/filepath" + + "github.com/fluxorio/fluxor/pkg/entrypoint" +) + +// Listen addresses, overridable via env so multiple instances / busy hosts can +// pick free ports. Defaults match a clean machine. +var ( + edgeAddr = envOr("EDGE_ADDR", ":8080") // data plane: worker routes + adminAddr = envOr("ADMIN_ADDR", ":8090") // control plane: management API +) + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func main() { + app, err := entrypoint.NewMainVerticle("") + if err != nil { + log.Fatalf("bootstrap: %v", err) + } + + // CPU layer: registry of workers + lightweight language engines + KV store. + registry := NewRegistry() + engines := DefaultEngines(DefaultEngineTimeout) + kv := NewKVStore() + logEngineAvailability(engines) + + // Deploy the dispatch verticle (event loop + worker pool). + edge := NewEdgeVerticle(registry, engines, kv) + if _, err := app.DeployVerticle(edge); err != nil { + log.Fatalf("deploy edge verticle: %v", err) + } + + // Cron triggers: schedule workers that declare a cron expression. + cron := NewCronManager(context.Background(), edge, registry) + if err := cron.Start(context.Background()); err != nil { + log.Fatalf("start cron: %v", err) + } + + // Seed the example workers shipped under ./workers. + seedExampleWorkers(registry, engines, cron) + + // HTTP/IO layer: gateway (data plane) + management API (control plane). + adminToken := os.Getenv("ADMIN_TOKEN") + if adminToken == "" { + log.Print("⚠️ ADMIN_TOKEN not set — management API is UNAUTHENTICATED") + } else { + log.Print("🔐 management API requires Authorization: Bearer ") + } + gw := NewGateway(edge, registry, engines, cron, adminToken) + go func() { + if err := gw.ServeEdge(edgeAddr); err != nil { + log.Fatalf("edge gateway: %v", err) + } + }() + go func() { + if err := gw.ServeAdmin(adminAddr); err != nil { + log.Fatalf("management API: %v", err) + } + }() + + printBanner() + + // Blocks until SIGINT/SIGTERM, then gracefully drains verticles. + if err := app.Start(); err != nil { + log.Fatalf("run: %v", err) + } +} + +// seedExampleWorkers deploys the bundled demo scripts so the runtime is usable +// immediately after start. Only seeds workers whose engine is installed. +func seedExampleWorkers(registry *Registry, engines EngineSet, cron *CronManager) { + dir, err := filepath.Abs("workers") + if err != nil { + return + } + examples := []*Worker{ + {Name: "hello-php", Route: "/hello", Runtime: RuntimePHP, Script: filepath.Join(dir, "hello.php"), + Env: map[string]string{"GREETING": "Xin chào"}}, + {Name: "echo-node", Route: "/echo", Runtime: RuntimeNode, Script: filepath.Join(dir, "echo.js")}, + // KV binding: a visit counter backed by the "counters" namespace. + {Name: "counter-php", Route: "/counter", Runtime: RuntimePHP, Script: filepath.Join(dir, "counter.php"), + KV: map[string]string{"COUNTERS": "counters"}}, + // Cron trigger: runs every minute, writing a heartbeat into KV. + {Name: "heartbeat-node", Route: "/heartbeat", Runtime: RuntimeNode, Script: filepath.Join(dir, "heartbeat.js"), + KV: map[string]string{"STATE": "heartbeat"}, Cron: "* * * * *"}, + } + for _, w := range examples { + if eng, ok := engines[w.Runtime]; ok && eng.Available() { + registry.Deploy(w) + if err := cron.Register(w); err != nil { + log.Printf("cron register %q failed: %v", w.Name, err) + } + log.Printf("seeded worker %q (%s) → %s%s", w.Name, w.Runtime, w.Route, cronSuffix(w)) + } else { + log.Printf("skipped worker %q — %s runtime not installed", w.Name, w.Runtime) + } + } +} + +func cronSuffix(w *Worker) string { + if w.Cron != "" { + return " [cron: " + w.Cron + "]" + } + return "" +} + +func logEngineAvailability(engines EngineSet) { + for rt, eng := range engines { + status := "available" + if !eng.Available() { + status = "NOT installed" + } + log.Printf("engine %-5s: %s", rt, status) + } +} + +func printBanner() { + log.Print("🌐 edge-workers running") + log.Printf(" data plane (gateway): http://localhost%s/", edgeAddr) + log.Printf(" control plane (admin): http://localhost%s/workers", adminAddr) + log.Print("") + log.Print(" curl http://localhost:8080/hello?name=Khang") + log.Print(` curl -X POST http://localhost:8080/echo -d '{"hi":1}'`) + log.Print(" curl http://localhost:8080/counter # KV-backed visit counter") + log.Print(" curl http://localhost:8090/workers # heartbeat-node runs every minute (cron)") +} diff --git a/apps/edge-workers/server.go b/apps/edge-workers/server.go new file mode 100644 index 0000000..0e2c48f --- /dev/null +++ b/apps/edge-workers/server.go @@ -0,0 +1,209 @@ +// Copyright (c) 2024-2028 Fluxor Framework +// All rights reserved. +// +// HTTP / IO front: +// - the edge gateway, which turns every inbound HTTP request into a +// WorkerRequest and feeds it to the EdgeVerticle (the CPU layer); +// - the management API (control plane) for deploying / listing / deleting +// workers at runtime, à la `wrangler`. + +package main + +import ( + "context" + "crypto/subtle" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "time" +) + +// Gateway is the HTTP/IO layer. It does no real work itself — it parses the +// request and hands off to the verticle, keeping the IO thread thin. +type Gateway struct { + verticle *EdgeVerticle + registry *Registry + engines EngineSet + cron *CronManager + adminToken string // bearer token for the management API ("" = unauthenticated) +} + +// NewGateway wires the HTTP layer to the CPU layer. adminToken, when non-empty, +// is required as `Authorization: Bearer ` on every management endpoint. +func NewGateway(v *EdgeVerticle, r *Registry, e EngineSet, cron *CronManager, adminToken string) *Gateway { + return &Gateway{verticle: v, registry: r, engines: e, cron: cron, adminToken: adminToken} +} + +// authOK reports whether the request carries the correct admin bearer token. +// Uses a constant-time compare to avoid leaking the token via timing. When no +// token is configured, the API is open (a startup warning is logged). +func (g *Gateway) authOK(r *http.Request) bool { + if g.adminToken == "" { + return true + } + const prefix = "Bearer " + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, prefix) { + return false + } + got := strings.TrimSpace(h[len(prefix):]) + return subtle.ConstantTimeCompare([]byte(got), []byte(g.adminToken)) == 1 +} + +// edgeHandler serves the data plane: every path is a candidate worker route. +func (g *Gateway) edgeHandler(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 8<<20)) // 8 MiB request cap + _ = r.Body.Close() + + req := WorkerRequest{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.RawQuery, + Headers: flattenHeaders(r.Header), + Body: string(body), + } + + // IO → CPU handoff: the verticle dispatches on its event loop and runs the + // script on its worker pool, then returns the response here. + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + resp := g.verticle.Dispatch(ctx, req) + + writeWorkerResponse(w, resp) +} + +// flattenHeaders collapses multi-valued headers to their first value for the +// script-facing contract (sufficient for the common case). +func flattenHeaders(h http.Header) map[string]string { + out := make(map[string]string, len(h)) + for k, v := range h { + if len(v) > 0 { + out[k] = v[0] + } + } + return out +} + +func writeWorkerResponse(w http.ResponseWriter, resp WorkerResponse) { + for k, v := range resp.Headers { + w.Header().Set(k, v) + } + if resp.Status == 0 { + resp.Status = 200 + } + w.WriteHeader(resp.Status) + _, _ = io.WriteString(w, resp.Body) +} + +// --- Management API (control plane) --- + +type deployRequest struct { + Name string `json:"name"` + Route string `json:"route"` + Runtime Runtime `json:"runtime"` + Script string `json:"script"` + Env map[string]string `json:"env"` + KV map[string]string `json:"kv"` // bindingName -> namespace + Cron string `json:"cron"` // optional cron expression +} + +// adminHandler implements REST CRUD over /workers. +func (g *Gateway) adminHandler(w http.ResponseWriter, r *http.Request) { + if !g.authOK(r) { + w.Header().Set("WWW-Authenticate", `Bearer realm="edge-workers admin"`) + writeJSON(w, 401, map[string]string{"error": "unauthorized: missing or invalid admin token"}) + return + } + switch { + case r.Method == http.MethodGet && r.URL.Path == "/workers": + writeJSON(w, 200, g.registry.List()) + + case r.Method == http.MethodPost && r.URL.Path == "/workers": + g.deployWorker(w, r) + + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/workers/"): + name := strings.TrimPrefix(r.URL.Path, "/workers/") + if g.registry.Delete(name) { + g.cron.Unregister(name) // stop any cron schedule for this worker + writeJSON(w, 200, map[string]string{"deleted": name}) + } else { + writeJSON(w, 404, map[string]string{"error": "worker not found: " + name}) + } + + default: + writeJSON(w, 404, map[string]string{"error": "unknown management route"}) + } +} + +func (g *Gateway) deployWorker(w http.ResponseWriter, r *http.Request) { + var dr deployRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&dr); err != nil { + writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + + switch { + case dr.Name == "": + writeJSON(w, 400, map[string]string{"error": "name is required"}) + return + case dr.Route == "" || !strings.HasPrefix(dr.Route, "/"): + writeJSON(w, 400, map[string]string{"error": "route must start with /"}) + return + case dr.Script == "": + writeJSON(w, 400, map[string]string{"error": "script path is required"}) + return + } + if _, ok := g.engines[dr.Runtime]; !ok { + writeJSON(w, 400, map[string]string{"error": "unsupported runtime: " + string(dr.Runtime)}) + return + } + if _, err := os.Stat(dr.Script); err != nil { + writeJSON(w, 400, map[string]string{"error": "script not found: " + dr.Script}) + return + } + + worker := &Worker{ + Name: dr.Name, + Route: dr.Route, + Runtime: dr.Runtime, + Script: dr.Script, + Env: dr.Env, + KV: dr.KV, + Cron: dr.Cron, + DeployedAt: time.Now(), + } + // Register the cron schedule first so a malformed expression is rejected + // before the worker is added to the registry (keeps the two consistent). + if err := g.cron.Register(worker); err != nil { + writeJSON(w, 400, map[string]string{"error": err.Error()}) + return + } + g.registry.Deploy(worker) + writeJSON(w, 201, worker) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +// ServeEdge starts the data-plane gateway listener (blocking). +func (g *Gateway) ServeEdge(addr string) error { + mux := http.NewServeMux() + mux.HandleFunc("/", g.edgeHandler) + return (&http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}).ListenAndServe() +} + +// ServeAdmin starts the control-plane management listener (blocking). +func (g *Gateway) ServeAdmin(addr string) error { + mux := http.NewServeMux() + mux.HandleFunc("/workers", g.adminHandler) + mux.HandleFunc("/workers/", g.adminHandler) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, map[string]any{"status": "ok", "workers": len(g.registry.List())}) + }) + return (&http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}).ListenAndServe() +} diff --git a/apps/edge-workers/verticle.go b/apps/edge-workers/verticle.go new file mode 100644 index 0000000..7da7782 --- /dev/null +++ b/apps/edge-workers/verticle.go @@ -0,0 +1,150 @@ +// Copyright (c) 2024-2028 Fluxor Framework +// All rights reserved. +// +// EdgeVerticle — the CPU layer of the edge-workers runtime. +// +// It embeds core.BaseVerticle, so it inherits the Fluxor execution model: +// +// - a per-verticle event loop (single worker, sequential, race-free, < 20µs/task) +// used here only for the cheap dispatch step (route matching); +// - a worker pool for blocking work, used here to run the script subprocess so +// the event loop is never blocked by a slow PHP/Node invocation. +// +// This is the Vert.x pattern the gateway feeds into: +// +// HTTP/IO ─▶ RunOnEventLoop(match route) ─▶ SubmitBlockingFunc(run engine) ─▶ Future + +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/fluxorio/fluxor/pkg/core" + "github.com/fluxorio/fluxor/pkg/core/concurrency" +) + +// EdgeVerticle dispatches incoming requests to deployed workers. +type EdgeVerticle struct { + *core.BaseVerticle + + registry *Registry + engines EngineSet + kv *KVStore + logger core.Logger +} + +// NewEdgeVerticle builds the CPU-layer verticle around a registry, engine set, +// and KV store. +func NewEdgeVerticle(registry *Registry, engines EngineSet, kv *KVStore) *EdgeVerticle { + return &EdgeVerticle{ + BaseVerticle: core.NewBaseVerticle("edge-workers"), + registry: registry, + engines: engines, + kv: kv, + } +} + +// Start brings the verticle online (event loop + worker pool are created by the base). +func (v *EdgeVerticle) Start(ctx core.FluxorContext) error { + if err := v.BaseVerticle.Start(ctx); err != nil { + return err + } + v.logger = core.NewDefaultLogger() + v.logger.Info("EdgeVerticle started — event loop + worker pool ready") + return nil +} + +// Dispatch is the entry point called by the HTTP gateway for every request. +// +// Step 1 runs on the event loop: a fast, sequential route lookup that never blocks. +// Step 2 offloads the blocking script subprocess onto the worker pool and awaits +// the Future, so concurrent requests fan out across pool goroutines while the +// event loop stays responsive. +func (v *EdgeVerticle) Dispatch(ctx context.Context, req WorkerRequest) WorkerResponse { + // --- Step 1: route match on the event loop (CPU dispatch, < 20µs) --- + matchCh := make(chan *Worker, 1) + err := v.RunOnEventLoop(concurrency.TaskFunc(func(context.Context) error { + matchCh <- v.registry.Match(req.Path) + return nil + })) + if err != nil { + return errorResponse(503, "edge verticle unavailable: "+err.Error()) + } + worker := <-matchCh + if worker == nil { + return errorResponse(404, "no worker bound to "+req.Path) + } + + if req.Trigger == "" { + req.Trigger = "fetch" + } + return v.RunWorker(ctx, worker, req) +} + +// RunWorker executes a specific worker for a request, bypassing route matching. +// Used both by Dispatch (after a match) and by the cron scheduler (scheduled +// triggers target a worker directly). The blocking subprocess runs on the worker +// pool so the event loop stays free. +func (v *EdgeVerticle) RunWorker(ctx context.Context, worker *Worker, req WorkerRequest) WorkerResponse { + engine, ok := v.engines[worker.Runtime] + if !ok { + return errorResponse(500, "no engine for runtime "+string(worker.Runtime)) + } + + req.Worker = worker.Name + req.KV = v.kv.snapshotBindings(worker.KV) // hand the script its KV view + + future := core.SubmitBlockingFunc(v.BaseVerticle, func() (WorkerResponse, error) { + return v.execute(ctx, engine, worker, req) + }) + + resp, err := future.Await(ctx) + if err != nil { + return errorResponse(502, "worker execution failed: "+err.Error()) + } + + // Write-back: apply any KV mutations the script requested. + if len(resp.KV) > 0 { + v.kv.applyOps(worker.KV, resp.KV) + } + return resp +} + +// execute serialises the request, runs the engine subprocess, and parses the result. +// Runs on a worker-pool goroutine — blocking here is expected and safe. +func (v *EdgeVerticle) execute(ctx context.Context, engine Engine, worker *Worker, req WorkerRequest) (WorkerResponse, error) { + reqJSON, err := marshalRequest(req) + if err != nil { + return WorkerResponse{}, fmt.Errorf("marshal request: %w", err) + } + + stdout, err := engine.Run(ctx, worker.Script, reqJSON, workerEnv(worker)) + if err != nil { + return WorkerResponse{}, err + } + return parseResponse(stdout), nil +} + +// workerEnv builds the subprocess environment: the host environment plus the +// worker's own bindings (env vars / secrets), Cloudflare-style. +func workerEnv(w *Worker) []string { + env := os.Environ() + for k, val := range w.Env { + env = append(env, k+"="+val) + } + return env +} + +func errorResponse(status int, msg string) WorkerResponse { + return WorkerResponse{ + Status: status, + Headers: map[string]string{"Content-Type": "application/json; charset=utf-8"}, + Body: fmt.Sprintf(`{"error":%q}`, msg), + } +} + +// DefaultEngineTimeout bounds a single script invocation (CPU/wall) at the edge. +const DefaultEngineTimeout = 10 * time.Second diff --git a/apps/edge-workers/worker.go b/apps/edge-workers/worker.go new file mode 100644 index 0000000..c43df4a --- /dev/null +++ b/apps/edge-workers/worker.go @@ -0,0 +1,152 @@ +// Copyright (c) 2024-2028 Fluxor Framework +// All rights reserved. +// +// Worker model + the stdin/stdout JSON contract between the runtime and scripts. + +package main + +import ( + "encoding/json" + "strings" + "sync" + "time" +) + +// Worker is a deployed serverless function: a single script bound to a route and +// executed by one of the language engines. This mirrors a Cloudflare Worker — a +// fetch handler reachable at a route — but the handler is an external script. +type Worker struct { + Name string `json:"name"` // unique worker name + Route string `json:"route"` // path prefix, e.g. "/hello" + Runtime Runtime `json:"runtime"` // php | node + Script string `json:"script"` // absolute path to the script file + Env map[string]string `json:"env"` // per-worker bindings (env vars / secrets) + KV map[string]string `json:"kv,omitempty"` // KV bindings: bindingName -> namespace + Cron string `json:"cron,omitempty"` // cron expr ("*/5 * * * *") triggering this worker + DeployedAt time.Time `json:"deployedAt"` // server-stamped at deploy time +} + +// WorkerRequest is the JSON document delivered to a script on stdin. +// It is the script's view of the incoming HTTP request — the "request" argument +// of a Workers fetch handler. +type WorkerRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + // Worker is metadata the script may inspect (its own name / route). + Worker string `json:"worker"` + // KV is a snapshot of every bound namespace: kv[binding][key] = value. + KV map[string]map[string]string `json:"kv,omitempty"` + // Trigger is "fetch" for HTTP requests or "scheduled" for cron invocations. + Trigger string `json:"trigger"` + // Cron carries the cron expression on scheduled invocations. + Cron string `json:"cron,omitempty"` +} + +// WorkerResponse is the JSON document a script emits on stdout — the "response" +// returned from a fetch handler. If a script writes non-JSON output, the runtime +// falls back to treating raw stdout as a text/plain body with status 200. +type WorkerResponse struct { + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + // KV holds mutations the script wants applied to its bound namespaces after + // the response is produced (write-back). Empty for read-only workers. + KV []KVOp `json:"kv,omitempty"` +} + +// marshalRequest encodes the request for delivery to a script on stdin. +func marshalRequest(req WorkerRequest) ([]byte, error) { + return json.Marshal(req) +} + +// parseResponse decodes a script's stdout into a WorkerResponse, falling back to +// raw text when the output is not a well-formed WorkerResponse JSON object. +func parseResponse(stdout []byte) WorkerResponse { + trimmed := strings.TrimSpace(string(stdout)) + if strings.HasPrefix(trimmed, "{") { + var r WorkerResponse + if err := json.Unmarshal([]byte(trimmed), &r); err == nil && r.Status != 0 { + if r.Headers == nil { + r.Headers = map[string]string{} + } + return r + } + } + // Fallback: treat stdout as a plain-text body. + return WorkerResponse{ + Status: 200, + Headers: map[string]string{"Content-Type": "text/plain; charset=utf-8"}, + Body: trimmed, + } +} + +// Registry is the thread-safe control plane holding deployed workers and their +// route table. Reads (request dispatch) are the hot path and take only a read lock. +type Registry struct { + mu sync.RWMutex + workers map[string]*Worker // by name +} + +// NewRegistry creates an empty worker registry. +func NewRegistry() *Registry { + return &Registry{workers: make(map[string]*Worker)} +} + +// Deploy adds or replaces a worker. It is the equivalent of `wrangler deploy`. +// DeployedAt is stamped here when the caller left it zero. +func (r *Registry) Deploy(w *Worker) { + if w.DeployedAt.IsZero() { + w.DeployedAt = time.Now() + } + r.mu.Lock() + defer r.mu.Unlock() + r.workers[w.Name] = w +} + +// Delete removes a worker by name, reporting whether it existed. +func (r *Registry) Delete(name string) bool { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.workers[name]; !ok { + return false + } + delete(r.workers, name) + return true +} + +// Get returns a worker by name, or nil if absent. +func (r *Registry) Get(name string) *Worker { + r.mu.RLock() + defer r.mu.RUnlock() + return r.workers[name] +} + +// List returns a snapshot of all deployed workers. +func (r *Registry) List() []*Worker { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*Worker, 0, len(r.workers)) + for _, w := range r.workers { + out = append(out, w) + } + return out +} + +// Match resolves a request path to a worker using longest-prefix routing, so a +// more specific route ("/api/users") wins over a broader one ("/api"). +func (r *Registry) Match(path string) *Worker { + r.mu.RLock() + defer r.mu.RUnlock() + var best *Worker + for _, w := range r.workers { + if path == w.Route || strings.HasPrefix(path, strings.TrimRight(w.Route, "/")+"/") { + if best == nil || len(w.Route) > len(best.Route) { + best = w + } + } + } + return best +} diff --git a/apps/edge-workers/workers/counter.php b/apps/edge-workers/workers/counter.php new file mode 100644 index 0000000..7b97170 --- /dev/null +++ b/apps/edge-workers/workers/counter.php @@ -0,0 +1,30 @@ + 200, + 'headers' => ['Content-Type' => 'application/json; charset=utf-8'], + 'body' => json_encode([ + 'visits' => $count, + 'worker' => $req['worker'] ?? null, + 'runtime' => 'php', + ]), + // Write-back: persist the new count into the bound namespace. + 'kv' => [ + ['binding' => 'COUNTERS', 'op' => 'put', 'key' => 'visits', 'value' => (string)$count], + ], +]; + +echo json_encode($response); diff --git a/apps/edge-workers/workers/echo.js b/apps/edge-workers/workers/echo.js new file mode 100644 index 0000000..a24f3aa --- /dev/null +++ b/apps/edge-workers/workers/echo.js @@ -0,0 +1,30 @@ +// echo.js — an edge worker (Node runtime). +// +// Reads the JSON request from stdin and echoes it back, demonstrating the same +// stdin/stdout contract as the PHP worker. This is the Node equivalent of a +// Cloudflare Workers fetch handler. + +const chunks = []; +process.stdin.on("data", (c) => chunks.push(c)); +process.stdin.on("end", () => { + let req = {}; + try { + req = JSON.parse(Buffer.concat(chunks).toString() || "{}"); + } catch { + req = {}; + } + + const response = { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + message: "echo from node", + worker: req.worker ?? null, + runtime: "node", + method: req.method ?? null, + received: req.body ?? "", + }), + }; + + process.stdout.write(JSON.stringify(response)); +}); diff --git a/apps/edge-workers/workers/heartbeat.js b/apps/edge-workers/workers/heartbeat.js new file mode 100644 index 0000000..4acd3ad --- /dev/null +++ b/apps/edge-workers/workers/heartbeat.js @@ -0,0 +1,47 @@ +// heartbeat.js — a cron-triggered edge worker (Node runtime). +// +// Bound to a cron expression ("* * * * *"), it fires on schedule with +// request.trigger === "scheduled". It also answers HTTP GET /heartbeat, reporting +// the last scheduled run from KV. Same script handles both triggers — pure logic. + +const chunks = []; +process.stdin.on("data", (c) => chunks.push(c)); +process.stdin.on("end", () => { + let req = {}; + try { + req = JSON.parse(Buffer.concat(chunks).toString() || "{}"); + } catch { + req = {}; + } + + const state = (req.kv && req.kv.STATE) || {}; + + if (req.trigger === "scheduled") { + // Cron tick: record a heartbeat and bump the tick counter. + const ticks = parseInt(state.ticks || "0", 10) + 1; + const now = new Date().toISOString(); + const response = { + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ heartbeat: now, ticks }), + kv: [ + { binding: "STATE", op: "put", key: "ticks", value: String(ticks) }, + { binding: "STATE", op: "put", key: "last", value: now }, + ], + }; + process.stdout.write(JSON.stringify(response)); + return; + } + + // HTTP fetch: report the current heartbeat state. + const response = { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + runtime: "node", + ticks: parseInt(state.ticks || "0", 10), + lastTick: state.last || null, + }), + }; + process.stdout.write(JSON.stringify(response)); +}); diff --git a/apps/edge-workers/workers/hello.php b/apps/edge-workers/workers/hello.php new file mode 100644 index 0000000..9df310a --- /dev/null +++ b/apps/edge-workers/workers/hello.php @@ -0,0 +1,28 @@ + 200, + 'headers' => ['Content-Type' => 'application/json; charset=utf-8'], + 'body' => json_encode([ + 'message' => "$greeting, $name!", + 'worker' => $req['worker'] ?? null, + 'runtime' => 'php', + 'method' => $req['method'] ?? null, + ]), +]; + +echo json_encode($response);