mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat: enhance SSE handling and API integration with improved event streaming and buffering configurations
This commit is contained in:
@@ -33,7 +33,12 @@ web:
|
||||
cd web && (test -d node_modules || npm install --no-fund --no-audit --loglevel=error) && npm run build
|
||||
|
||||
web-dev:
|
||||
cd web && (test -d node_modules || npm install --no-fund --no-audit --loglevel=error) && npm run dev
|
||||
# NEXT_PUBLIC_FLOW_API_URL points the SSE EventSource straight at the
|
||||
# Go API so streams skip Next's trailingSlash 308 redirect (which
|
||||
# EventSource doesn't follow). Override at the command line if your
|
||||
# Go API runs elsewhere.
|
||||
cd web && (test -d node_modules || npm install --no-fund --no-audit --loglevel=error) \
|
||||
&& NEXT_PUBLIC_FLOW_API_URL=$${NEXT_PUBLIC_FLOW_API_URL:-http://localhost:8090} npm run dev
|
||||
|
||||
# `make dev` runs the Next.js dev server (auto-installs deps).
|
||||
# In another terminal run `make watch` to hot-reload the Go API on :8090;
|
||||
|
||||
+8
-2
@@ -538,13 +538,19 @@ func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize the ref to the bare branch name (`main`, not `refs/heads/main`)
|
||||
// so downstream nodes — particularly Build's git clone --branch — don't
|
||||
// have to know about Git's internal ref namespace. `fullRef` is kept for
|
||||
// nodes that want the original.
|
||||
branch := github.BranchFromRef(push.Ref)
|
||||
trigger := []map[string]any{{
|
||||
"source": "github_push",
|
||||
"agentId": a.ID,
|
||||
"agentName": a.Name,
|
||||
"repoUrl": a.RepoURL,
|
||||
"ref": push.Ref,
|
||||
"branch": github.BranchFromRef(push.Ref),
|
||||
"ref": branch,
|
||||
"branch": branch,
|
||||
"fullRef": push.Ref,
|
||||
"commit": push.After,
|
||||
"pusher": push.Pusher.Name,
|
||||
}}
|
||||
|
||||
+26
-5
@@ -27,6 +27,14 @@ import (
|
||||
"github.com/lyzrai/flow/pkg/storage"
|
||||
)
|
||||
|
||||
// ssePrimer is a >2 KB SSE comment block sent as the first chunk of every
|
||||
// stream response. Buffer-aware proxies (Cloudflare quick-tunnels, some CDN
|
||||
// edges, http/2 windowing on slow links) hold back small chunks until they
|
||||
// reach a flush threshold. Padding past that threshold makes node-by-node
|
||||
// events arrive immediately instead of in one burst at the end of the run.
|
||||
// Lines beginning with `:` are SSE comments — clients ignore them silently.
|
||||
var ssePrimer = ":" + strings.Repeat(" ", 2049) + "\n\n"
|
||||
|
||||
// FlowSummary is the list-shape returned to the dashboard.
|
||||
type FlowSummary struct {
|
||||
ID string `json:"id"`
|
||||
@@ -133,10 +141,18 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("DELETE /api/workflows/{id}", s.handleDeleteFlow)
|
||||
s.mux.HandleFunc("POST /api/workflows/execute", s.handleExecuteWorkflow)
|
||||
s.mux.HandleFunc("GET /api/executions", s.handleListExecutions)
|
||||
// Stream routes register both with-and-without trailing slash so the
|
||||
// Next dev server's `trailingSlash: true` rewrite (which appends "/")
|
||||
// reaches the same handler as a direct call. Without this the SSE
|
||||
// stream returns the literal redirect text via the Next proxy and the
|
||||
// UI never sees node_started/log events.
|
||||
s.mux.HandleFunc("GET /api/runs/stream", s.handleRunsStream)
|
||||
s.mux.HandleFunc("GET /api/runs/stream/", s.handleRunsStream)
|
||||
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
|
||||
s.mux.HandleFunc("GET /api/executions/{id}/stream", s.handleStreamExecution)
|
||||
s.mux.HandleFunc("GET /api/executions/{id}/stream/", s.handleStreamExecution)
|
||||
s.mux.HandleFunc("GET /api/executions/{id}/logs/{node}", s.handleNodeLog)
|
||||
s.mux.HandleFunc("GET /api/executions/{id}/logs/{node}/", s.handleNodeLog)
|
||||
s.mux.HandleFunc("POST /api/executions/{id}/resume", s.handleResumeExecution)
|
||||
|
||||
// Agents — Langship-style agent registry (git URL + PAT)
|
||||
@@ -535,16 +551,19 @@ func (s *Server) handleStreamExecution(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
ch, cancel := s.events.Subscribe(id)
|
||||
defer cancel()
|
||||
|
||||
// Tell the client which execution it's subscribed to (also primes the
|
||||
// SSE pipe so flushers in the middle don't withhold the first byte).
|
||||
// Prime the stream with a >2 KB comment so intermediate proxies that
|
||||
// buffer based on byte threshold (Cloudflare quick-tunnel, some CDN
|
||||
// edges) flush past the threshold immediately. SSE comments start with
|
||||
// `:` and are ignored by EventSource.
|
||||
_, _ = fmt.Fprint(w, ssePrimer)
|
||||
_, _ = fmt.Fprintf(w, "event: open\ndata: {\"execution_id\":%q}\n\n", id)
|
||||
flusher.Flush()
|
||||
|
||||
@@ -586,7 +605,7 @@ func (s *Server) handleRunsStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -594,6 +613,8 @@ func (s *Server) handleRunsStream(w http.ResponseWriter, r *http.Request) {
|
||||
ch, cancel := s.runsBus.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
// See handleStreamExecution for why this padding is required.
|
||||
_, _ = fmt.Fprint(w, ssePrimer)
|
||||
_, _ = fmt.Fprint(w, "event: open\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
|
||||
+90
-45
@@ -10,98 +10,143 @@ package execevents
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lyzrai/flow/pkg/engine"
|
||||
)
|
||||
|
||||
// retainPerExec is the cap on per-execution event history. We keep the
|
||||
// most-recent N events so a subscriber that arrives mid-run can replay
|
||||
// what it missed (node_started lifecycle events especially — those fire
|
||||
// fast, often before the UI has connected).
|
||||
const retainPerExec = 500
|
||||
|
||||
// retainAfterDone is how long to hold the buffer for an execution after
|
||||
// the terminal `done` event arrives. Late subscribers (e.g. a user who
|
||||
// opens the run page right after success) get the full replay.
|
||||
const retainAfterDone = 5 * time.Minute
|
||||
|
||||
// MemoryBus is a single-process publish/subscribe bus keyed by execution ID.
|
||||
// Subscribers receive every event published for their execution until they
|
||||
// unsubscribe or the channel buffer fills (slow subscribers are dropped to
|
||||
// keep the publisher non-blocking).
|
||||
// Subscribers receive every event published for their execution; on
|
||||
// subscribe they additionally receive a backlog replay of events emitted
|
||||
// before they connected. Bounded retention per exec keeps memory in check.
|
||||
type MemoryBus struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[string][]*subscription
|
||||
// terminal stores the final event per exec so a subscriber that arrives
|
||||
// late still gets a "done" / "error" event and closes cleanly.
|
||||
terminal map[string]engine.ExecutionEvent
|
||||
mu sync.RWMutex
|
||||
streams map[string]*execStream
|
||||
}
|
||||
|
||||
type execStream struct {
|
||||
subs []*subscription
|
||||
history []engine.ExecutionEvent
|
||||
done bool
|
||||
doneAt time.Time
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
ch chan engine.ExecutionEvent
|
||||
closed bool
|
||||
once sync.Once
|
||||
ch chan engine.ExecutionEvent
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewMemoryBus returns a fresh in-memory bus.
|
||||
// NewMemoryBus returns a fresh in-memory bus and starts a background
|
||||
// sweeper that drops stale streams. The returned bus has no Close — the
|
||||
// process owns the lifecycle.
|
||||
func NewMemoryBus() *MemoryBus {
|
||||
return &MemoryBus{
|
||||
subscribers: map[string][]*subscription{},
|
||||
terminal: map[string]engine.ExecutionEvent{},
|
||||
}
|
||||
b := &MemoryBus{streams: map[string]*execStream{}}
|
||||
go b.sweep()
|
||||
return b
|
||||
}
|
||||
|
||||
// Emit implements engine.Emitter. Non-blocking: if a subscriber's channel is
|
||||
// full we drop the event for that subscriber (publisher must not stall).
|
||||
// Emit implements engine.Emitter. Non-blocking: if a subscriber's channel
|
||||
// is full the event is dropped for that subscriber (publisher must never
|
||||
// stall) but stays in the per-exec history so a fresh subscriber can still
|
||||
// see it.
|
||||
func (b *MemoryBus) Emit(_ context.Context, execID string, e engine.ExecutionEvent) {
|
||||
if execID == "" {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
subs := append([]*subscription(nil), b.subscribers[execID]...)
|
||||
if isTerminal(e.Type) {
|
||||
b.terminal[execID] = e
|
||||
st := b.streams[execID]
|
||||
if st == nil {
|
||||
st = &execStream{}
|
||||
b.streams[execID] = st
|
||||
}
|
||||
// Append + cap.
|
||||
st.history = append(st.history, e)
|
||||
if over := len(st.history) - retainPerExec; over > 0 {
|
||||
st.history = st.history[over:]
|
||||
}
|
||||
if e.Type == engine.EventDone {
|
||||
st.done = true
|
||||
st.doneAt = time.Now()
|
||||
}
|
||||
subs := append([]*subscription(nil), st.subs...)
|
||||
b.mu.Unlock()
|
||||
|
||||
for _, s := range subs {
|
||||
select {
|
||||
case s.ch <- e:
|
||||
default:
|
||||
// drop — slow subscriber
|
||||
// slow subscriber — drop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives every event for execID. The
|
||||
// caller must call the returned cancel func when done. If a terminal event
|
||||
// was already published before subscribe, it is replayed once so the caller
|
||||
// can shut down cleanly.
|
||||
// Subscribe registers for events for execID. On subscribe the caller
|
||||
// receives every event already retained for this execution (in order),
|
||||
// followed by every new event. Returns the channel and a cancel func.
|
||||
func (b *MemoryBus) Subscribe(execID string) (<-chan engine.ExecutionEvent, func()) {
|
||||
s := &subscription{ch: make(chan engine.ExecutionEvent, 32)}
|
||||
// Buffer ≥ history cap so the initial replay never drops.
|
||||
s := &subscription{ch: make(chan engine.ExecutionEvent, retainPerExec+32)}
|
||||
|
||||
b.mu.Lock()
|
||||
b.subscribers[execID] = append(b.subscribers[execID], s)
|
||||
term, hadTerm := b.terminal[execID]
|
||||
st := b.streams[execID]
|
||||
if st == nil {
|
||||
st = &execStream{}
|
||||
b.streams[execID] = st
|
||||
}
|
||||
st.subs = append(st.subs, s)
|
||||
// Snapshot history under the lock.
|
||||
backlog := append([]engine.ExecutionEvent(nil), st.history...)
|
||||
b.mu.Unlock()
|
||||
|
||||
if hadTerm {
|
||||
// non-blocking — buffer is fresh
|
||||
s.ch <- term
|
||||
// Replay outside the lock. Buffer is sized so this never blocks.
|
||||
for _, e := range backlog {
|
||||
s.ch <- e
|
||||
}
|
||||
|
||||
cancel := func() {
|
||||
s.once.Do(func() {
|
||||
b.mu.Lock()
|
||||
cur := b.subscribers[execID]
|
||||
out := cur[:0]
|
||||
for _, x := range cur {
|
||||
if x != s {
|
||||
out = append(out, x)
|
||||
st := b.streams[execID]
|
||||
if st != nil {
|
||||
out := st.subs[:0]
|
||||
for _, x := range st.subs {
|
||||
if x != s {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
delete(b.subscribers, execID)
|
||||
} else {
|
||||
b.subscribers[execID] = out
|
||||
st.subs = out
|
||||
}
|
||||
b.mu.Unlock()
|
||||
s.closed = true
|
||||
close(s.ch)
|
||||
})
|
||||
}
|
||||
return s.ch, cancel
|
||||
}
|
||||
|
||||
func isTerminal(t engine.EventType) bool {
|
||||
return t == engine.EventDone
|
||||
// sweep periodically GC's streams that are done + past the retention
|
||||
// window AND have no live subscribers.
|
||||
func (b *MemoryBus) sweep() {
|
||||
t := time.NewTicker(1 * time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
now := time.Now()
|
||||
b.mu.Lock()
|
||||
for id, st := range b.streams {
|
||||
if st.done && len(st.subs) == 0 && now.Sub(st.doneAt) > retainAfterDone {
|
||||
delete(b.streams, id)
|
||||
}
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -76,7 +76,11 @@ func (e *BuildExecutor) Execute(ctx context.Context, node models.NodeDef, inputs
|
||||
}
|
||||
|
||||
commitSHA, _ := trigger["commit"].(string)
|
||||
ref := strFirst(strFromAny(trigger["ref"]), a.Ref, "main")
|
||||
// `git clone --branch` wants a bare name like "main"; if a webhook
|
||||
// payload (or older trigger record) carried "refs/heads/main", trim it
|
||||
// so the clone doesn't fail with "Remote branch refs/heads/main not
|
||||
// found in upstream origin".
|
||||
ref := stripRefsHeads(strFirst(strFromAny(trigger["ref"]), a.Ref, "main"))
|
||||
|
||||
cloneDir, cleanup, err := cloneRepo(ctx, a, ref, commitSHA, time.Duration(timeoutSec)*time.Second)
|
||||
if err != nil {
|
||||
@@ -511,6 +515,18 @@ func oneLineSummary(s string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// stripRefsHeads turns "refs/heads/main" into "main"; passes any other
|
||||
// shape through unchanged. Tags ("refs/tags/v1") would still need a
|
||||
// different clone strategy (--branch works for both branches and tags so
|
||||
// we leave those alone).
|
||||
func stripRefsHeads(s string) string {
|
||||
const p = "refs/heads/"
|
||||
if strings.HasPrefix(s, p) {
|
||||
return s[len(p):]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sanitizeEnvValue(s string) string {
|
||||
r := strings.NewReplacer("\n", " ", "\r", " ", "\x00", "")
|
||||
return r.Replace(s)
|
||||
|
||||
@@ -75,6 +75,12 @@ function ExecutionView() {
|
||||
const [run, setRun] = useState<Run | null>(null);
|
||||
const [pipelineDef, setPipelineDef] = useState<PipelineDefinition | null>(null);
|
||||
const [nodeStatuses, setNodeStatuses] = useState<NodeStatuses>({});
|
||||
// Per-node tick when we first marked it running. Used to enforce a
|
||||
// minimum visible "running" duration so the user always sees the
|
||||
// spinner — even for instantaneous nodes (Trigger, NoOp). Without
|
||||
// this, fast nodes flicker pending → success in one render batch and
|
||||
// the running state is invisible.
|
||||
const runningSinceRef = useRef<Record<string, number>>({});
|
||||
const [nodeLogs, setNodeLogs] = useState<NodeLogs>({});
|
||||
const [nodeDurations, setNodeDurations] = useState<NodeDurations>({});
|
||||
const [streamConnected, setStreamConnected] = useState(false);
|
||||
@@ -130,22 +136,39 @@ function ExecutionView() {
|
||||
return;
|
||||
}
|
||||
if (ev.node) {
|
||||
setNodeStatuses((prev) => {
|
||||
const next: NodeStatus =
|
||||
ev.type === "node_started"
|
||||
? "running"
|
||||
: ev.type === "node_completed"
|
||||
? "success"
|
||||
: ev.type === "node_error"
|
||||
? "failed"
|
||||
: (prev[ev.node!] ?? "pending");
|
||||
return { ...prev, [ev.node!]: next };
|
||||
});
|
||||
if (ev.type === "node_completed" || ev.type === "node_error") {
|
||||
const node = ev.node;
|
||||
const minVisibleMs = 400;
|
||||
|
||||
if (ev.type === "node_started") {
|
||||
runningSinceRef.current[node] = Date.now();
|
||||
setNodeStatuses((prev) => ({ ...prev, [node]: "running" }));
|
||||
} else if (ev.type === "node_completed" || ev.type === "node_error") {
|
||||
const final: NodeStatus =
|
||||
ev.type === "node_completed" ? "success" : "failed";
|
||||
const startedAt = runningSinceRef.current[node];
|
||||
const elapsed = startedAt ? Date.now() - startedAt : Infinity;
|
||||
|
||||
// Make sure the user actually sees a "running" frame. If we
|
||||
// never recorded a start (subscriber arrived after the start
|
||||
// event flushed) we apply the terminal state immediately.
|
||||
if (startedAt === undefined || elapsed >= minVisibleMs) {
|
||||
setNodeStatuses((prev) => ({ ...prev, [node]: final }));
|
||||
} else {
|
||||
// Briefly show "running" first if we missed it, then flip.
|
||||
setNodeStatuses((prev) => ({
|
||||
...prev,
|
||||
[node]: prev[node] === "running" ? "running" : "running",
|
||||
}));
|
||||
setTimeout(() => {
|
||||
setNodeStatuses((prev) => ({ ...prev, [node]: final }));
|
||||
}, minVisibleMs - elapsed);
|
||||
}
|
||||
delete runningSinceRef.current[node];
|
||||
|
||||
if (typeof ev.duration_ms === "number") {
|
||||
setNodeDurations((prev) => ({
|
||||
...prev,
|
||||
[ev.node!]: ev.duration_ms!,
|
||||
[node]: ev.duration_ms!,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ function CanvasInner({
|
||||
fullBleed,
|
||||
nodeStatuses,
|
||||
}: PipelineCanvasProps) {
|
||||
// (Hook order: nodes/edges state declared below so this comment sits at
|
||||
// the top of the component for context.)
|
||||
// Compute initial RF state once. The canvas owns it from here on.
|
||||
const initial = useMemo(() => toReactFlow(initialValue ?? null), []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: load-once
|
||||
@@ -78,6 +80,28 @@ function CanvasInner({
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// Merge external runStatus into the RF-owned node state. We can't just
|
||||
// pass a freshly-mapped `nodes` prop to <ReactFlow> because useNodesState
|
||||
// makes RF the source of truth — external props get overridden by the
|
||||
// internal store on the next render. Instead we patch the store directly
|
||||
// whenever nodeStatuses changes. Skips updates when the value is
|
||||
// unchanged so we don't churn React Flow on every poll tick.
|
||||
useEffect(() => {
|
||||
if (!nodeStatuses) return;
|
||||
setNodes((cur) =>
|
||||
cur.map((n) => {
|
||||
const next = nodeStatuses[n.id] ?? "pending";
|
||||
const prev =
|
||||
(n.data as FlowNodeData & { runStatus?: string }).runStatus ?? "pending";
|
||||
if (prev === next) return n;
|
||||
return {
|
||||
...n,
|
||||
data: { ...(n.data as FlowNodeData), runStatus: next },
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [nodeStatuses, setNodes]);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
@@ -233,17 +257,7 @@ function CanvasInner({
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={
|
||||
nodeStatuses
|
||||
? nodes.map((n) => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
runStatus: nodeStatuses[n.id] ?? "pending",
|
||||
},
|
||||
}))
|
||||
: nodes
|
||||
}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
|
||||
+27
-5
@@ -61,7 +61,24 @@ export type ServerConfig = {
|
||||
orchestratorEnabled: boolean;
|
||||
};
|
||||
|
||||
const base = ""; // same-origin
|
||||
// REST + page calls go same-origin (Next rewrite proxies /api → Go in dev,
|
||||
// nginx proxies /api → flow:8090 in prod).
|
||||
const base = "";
|
||||
|
||||
// SSE base for streaming endpoints.
|
||||
//
|
||||
// Default: same-origin (works behind any reverse proxy that doesn't buffer
|
||||
// — nginx with `proxy_buffering off`, our prod config; Cloudflare tunnels;
|
||||
// most production setups).
|
||||
//
|
||||
// Dev override: set NEXT_PUBLIC_FLOW_API_URL=http://localhost:8090 to hit
|
||||
// the Go server directly, bypassing Next's dev rewrite (which buffers
|
||||
// chunked responses, breaking node-by-node updates) and Next's 308 redirect
|
||||
// from `trailingSlash: true` (which EventSource won't follow).
|
||||
const sseBase =
|
||||
(typeof process !== "undefined" &&
|
||||
process.env?.NEXT_PUBLIC_FLOW_API_URL) ||
|
||||
"";
|
||||
|
||||
async function handle<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
@@ -168,11 +185,16 @@ export const api = {
|
||||
),
|
||||
|
||||
// --- runs ---
|
||||
/** Returns the EventSource URL for SSE streaming of an execution. */
|
||||
executionStreamURL: (id: string) => `${base}/api/executions/${id}/stream`,
|
||||
/** Returns the EventSource URL for SSE streaming of an execution.
|
||||
* Uses `sseBase` so dev can hit the Go API directly (skipping Next's
|
||||
* trailingSlash 308 which EventSource won't follow). Trailing slash on
|
||||
* the path keeps things consistent if the user does proxy through Next
|
||||
* or nginx; the Go mux registers both forms either way. */
|
||||
executionStreamURL: (id: string) => `${sseBase}/api/executions/${id}/stream/`,
|
||||
|
||||
/** Global runs feed — fires once per dispatched run. */
|
||||
runsStreamURL: () => `${base}/api/runs/stream`,
|
||||
/** Global runs feed — fires once per dispatched run. Same dev-bypass
|
||||
* reasoning as executionStreamURL. */
|
||||
runsStreamURL: () => `${sseBase}/api/runs/stream/`,
|
||||
|
||||
listRuns: (params?: { pipelineId?: string; limit?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
|
||||
+21
-2
@@ -12,8 +12,27 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Proxy API calls to the Go service.
|
||||
# `flow` is the service name on the docker-compose network.
|
||||
# SSE endpoints. Buffering off + long read timeout so events flush
|
||||
# node-by-node instead of getting stuck in nginx's buffer. Heartbeats
|
||||
# every 15s on the Go side keep the connection alive.
|
||||
location ~ ^/api/(executions/[^/]+/stream|runs/stream)/?$ {
|
||||
proxy_pass http://flow:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Disable everything that would prevent immediate event flushing.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 24h;
|
||||
proxy_send_timeout 24h;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
|
||||
# Regular JSON API.
|
||||
location /api/ {
|
||||
proxy_pass http://flow:8090;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
Reference in New Issue
Block a user