DEV Community

Cover image for Consuming Dify Workflow SSE Streams in React 19 with Vercel AI SDK
Ken
Ken

Posted on

Consuming Dify Workflow SSE Streams in React 19 with Vercel AI SDK

Self-hosting agentic platforms like langgenius/dify (recently shipping v1.17.x with hardened workflow streaming) solves internal orchestration, but hooking its streaming API directly into modern React frontends introduces sharp edge cases. Dify does not output standard OpenAI raw delta chunks by default; its workflow execution endpoints stream structured JSON events (workflow_started, node_started, text_chunk, workflow_finished).

If you pipe Dify's Server-Sent Events (SSE) directly into @ai-sdk/react or React 19 state setters without stream transformation, the UI will choke or fail on event parsing. Here is how to build an unbuffered bridge.

The Problem: Reverse Proxy Buffering & Custom SSE Framing

Two issues hit production teams early:

  1. Reverse-Proxy Buffering: Nginx or Envoy in front of Dify often buffers chunked HTTP responses until 4KB–8KB chunks accumulate, destroying the perceived typewriter effect.
  2. Protocol Mismatch: Vercel AI SDK's useChat and useCompletion expect OpenAI-compatible SSE chunks (data: {"choices":[...]}) or the AI SDK UI protocol format, not Dify workflow events.

Solution: Edge Route Adapter with TransformStream

Below is a Next.js App Router route (app/api/dify-stream/route.ts) that transforms Dify's workflow event stream into an OpenAI-compatible delta format consumed seamlessly by useCompletion or useChat.

import { NextRequest } from 'next/server';

export const runtime = 'edge';
export const dynamic = 'force-dynamic';

export async function POST(req: NextRequest) {
  const { query, inputs = {} } = await req.json();

  const difyRes = await fetch(`${process.env.DIFY_API_URL}/v1/workflows/run`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.DIFY_API_KEY}`,
      'Content-Type': 'application/json',
      // Disables reverse proxy response buffering upstream
      'X-Accel-Buffering': 'no',
    },
    body: JSON.stringify({
      inputs,
      response_mode: 'streaming',
      user: 'web-client',
    }),
  });

  if (!difyRes.ok || !difyRes.body) {
    return new Response('Dify upstream failure', { status: 502 });
  }

  const textDecoder = new TextDecoder();
  const textEncoder = new TextEncoder();

  const transformStream = new TransformStream({
    transform(chunk, controller) {
      const text = textDecoder.decode(chunk, { stream: true });
      const lines = text.split('\n');

      for (const line of lines) {
        if (!line.startsWith('data: ')) continue;
        const rawJson = line.slice(6).trim();
        if (!rawJson) continue;

        try {
          const payload = JSON.parse(rawJson);
          // Dify workflow text output event
          if (payload.event === 'text_chunk') {
            const delta = payload.data?.text || '';
            controller.enqueue(textEncoder.encode(`0:${JSON.stringify(delta)}\n`));
          } else if (payload.event === 'workflow_finished') {
            controller.enqueue(textEncoder.encode(`d:{"finishReason":"stop"}\n`));
          }
        } catch {
          // Skip heartbeat / keepalive fragments
        }
      }
    },
  });

  return new Response(difyRes.body.pipeThrough(transformStream), {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'X-Vercel-AI-Data-Stream': 'v1',
      'Cache-Control': 'no-cache, no-transform',
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Client-Side: React 19 Hook Integration

In React 19 client components, consumption remains clean via standard hooks:

'use client';

import { useCompletion } from '@ai-sdk/react';

export function DifyWorkflowViewer() {
  const { completion, input, handleInputChange, handleSubmit, isLoading, stop } = useCompletion({
    api: '/api/dify-stream',
  });

  return (
    <div className="p-4 max-w-xl mx-auto space-y-4">
      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Trigger workflow..."
          className="border p-2 rounded flex-1"
        />
        <button type="submit" disabled={isLoading} className="px-4 py-2 bg-blue-600 text-white rounded">
          Run
        </button>
        {isLoading && (
          <button type="button" onClick={stop} className="px-3 py-2 bg-red-600 text-white rounded">
            Abort
          </button>
        )}
      </form>

      <div className="p-4 bg-gray-900 text-gray-100 rounded min-h-[160px] whitespace-pre-wrap font-mono text-sm">
        {completion || (isLoading ? 'Receiving first chunk...' : 'Awaiting input.')}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This architecture decouples Dify's backend orchestration engine from your design system, eliminates UI stalls during heavy token generation, and ensures client-side AbortController cancellation works cleanly on network cuts.

Top comments (0)