
আমাদের আগের পর্বে আমরা যখন Next.js Edge Middleware দিয়ে আমাদের এজেন্টে Rate Limiting বসিয়েছিলাম, তখন অপ্রত্যাশিত বট ট্রাফিক এবং API বাজেট লিক হওয়া থেকে আমাদের DevPulse ব্যাকএন্ডকে সুরক্ষিত করেছিলাম। কিন্তু প্রোডাকশন সিস্টেমে নিরাপত্তা নিশ্চিত করার পরই দ্বিতীয় যে বড় ইঞ্জিনিয়ারিং চ্যালেঞ্জটি সামনে আসে তা হলো নেটওয়ার্ক নির্ভরযোগ্যতা (Stream Reliability): ধরুন আপনার একজন আসল ইউজার মোবাইল ডেটা (4G), কর্পোরেট ফায়ারওয়াল কিংবা Nginx রিভার্স প্রক্সির পেছন থেকে আপনার এজেন্টকে একটি জটিল প্রশ্ন করলেন—যার উত্তর দিতে গিয়ে এজেন্ট ব্যাকএন্ডে ৫ সেকেন্ড ধরে দুটি টুল চালাচ্ছে—এই অবস্থায় মাঝপথে স্ট্রিম কেটে যাওয়া বা ফ্রন্টএন্ডে ডেটা আটকে থাকা কীভাবে ঠেকাবেন?
Next.js প্রজেক্টে AI Agent-এর স্ট্রিমিংকে শতভাগ নির্ভরযোগ্য (Reliable) করার সবচেয়ে কার্যকর উপায় হলো একটি কাস্টম Server-Sent Events (SSE) পাইপলাইন তৈরি করা—যেখানে থাকবে অ্যান্টি-বাফারিং হেডার (Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, এবং X-Accel-Buffering: no), প্রতি ৩ সেকেন্ডে একটি Heartbeat Keep-Alive পালস, এবং ফ্রন্টএন্ডে একটি \n\n ডেলিমিটার বাফার পার্সার। এই আর্কিটেকচারটি নিশ্চিত করে যেন কোনো প্রক্সি সার্ভার ছোট টোকেনগুলোকে আটকে না রাখে, ধীরগতির এক্সটার্নাল টুল চলাকালীন কানেকশন কখনোই টাইমআউট না হয় এবং নেটওয়ার্কে ভেঙে আসা TCP প্যাকেটগুলো ফ্রন্টএন্ডে নিখুঁতভাবে জোড়া লাগে।
১৫ ও ১৬তম পর্বে আমরা Vercel AI SDK-এর বিল্ট-ইন toDataStreamResponse() এবং useChat হুক দিয়ে খুব দ্রুত স্ট্রিমিং চালু করেছিলাম। সাধারণ চ্যাট ইন্টারফেসের জন্য সেটি দারুণ কাজ করলেও, আপনি যখন কাস্টম প্রক্সি সার্ভার, ডকার ভিপিএস, মোবাইল অ্যাপ কিংবা ব্রাউজার এক্সটেনশনের জন্য ইন্ডাস্ট্রি-স্ট্যান্ডার্ড স্ট্রিমিং বানাবেন, তখন Server-Sent Events (SSE)-এর একদম ভেতরের মেকানিজম নিজের নিয়ন্ত্রণে থাকাটা একজন সিনিয়র ডেভেলপারের সবচেয়ে বড় শক্তি।
বাস্তব প্রোডাকশন নেটওয়ার্কে সাধারণ স্ট্রিমিং কেন ভেঙে পড়ে?
আপনি যখন নিজের ল্যাপটপে localhost:3000-এ স্ট্রিমিং টেস্ট করেন, তখন সার্ভার এবং ব্রাউজারের মাঝখানে কোনো তৃতীয় পক্ষ থাকে না। কিন্তু অ্যাপটি প্রোডাকশনে যাওয়ার পর প্রতিটি স্ট্রিম চাঙ্ককে CDN, Load Balancer, Nginx Proxy এবং মোবাইল অপারেটরের রাউটার পার হয়ে আসতে হয়।
সঠিক SSE কনফিগারেশন না থাকলে প্রোডাকশনে ৪টি মারাত্মক সমস্যা দেখা দেয়:
১. Proxy ও CDN Buffering: অনেক রিভার্স প্রক্সি (যেমন Nginx বা Cloudflare-এর কিছু কম্প্রেশন মোড) ছোট ছোট ডেটা চাঙ্ক সাথে সাথে ব্রাউজারে না পাঠিয়ে ৪ কিলোবাইট বা ৮ কিলোবাইট পূর্ণ হওয়া পর্যন্ত বাফারে (Buffer) জমিয়ে রাখে। ফলে LLM সেকেন্ডে ৭০টি শব্দ জেনারেট করলেও ইউজার ৪ সেকেন্ড সাদা স্ক্রিন দেখেন এবং হঠাৎ একসাথে বড় একটি প্যারাগ্রাফ পান!
২. টুল চলাকালীন Idle Socket Timeout: আমাদের এজেন্ট যখন গিটহাব API (inspectLiveGitHubIssue) বা ভেক্টর সার্চ কল করে, তখন ৪ থেকে ৬ সেকেন্ড পর্যন্ত ব্যাকএন্ড থেকে কোনো টেক্সট টোকেন বের হয় না। অনেক মোবাইল নেটওয়ার্ক এবং কর্পোরেট ফায়ারওয়াল দেখে যে কানেকশন দিয়ে ৪-৫ সেকেন্ড কোনো ডেটা যাচ্ছে না—তখন তারা সকেটটিকে 'মৃত' ভেবে কানেকশন কেটে দেয়!
৩. TCP Packet Coalescing ও খণ্ডিত JSON: বাস্তব ইন্টারনেটে সার্ভারের দুটি আলাদা enqueue() কল একসাথে জুড়ে গিয়ে ব্রাউজারের একটি reader.read() কলে চলে আসতে পারে, আবার বড় একটি টুল রেজাল্টের JSON মাঝখান দিয়ে দুই টুকরো হয়ে দুই প্যাকেটে আসতে পারে। সাধারণ কোডে সরাসরি JSON.parse() চালালে সাথে সাথে সিনট্যাক্স এরর হয়ে স্ট্রিম বন্ধ হয়ে যায়।
৪. মাল্টি-প্ল্যাটফর্ম সাপোর্ট: আপনি যদি ভবিষ্যতে আপনার এই একই Next.js এজেন্ট ব্যাকএন্ডকে কোনো ফ্লাটার (Flutter) মোবাইল অ্যাপ, ব্রাউজার এক্সটেনশন বা অন্য কোনো সার্ভিস থেকে কল করতে চান, তখন তাদের জন্য স্ট্যান্ডার্ড event: এবং data: SSE ফরম্যাট প্রয়োজন হয়।

আমাদের Reliable SSE পাইপলাইন কীভাবে কাজ করে?
স্ট্যান্ডার্ড Server-Sent Events (SSE) প্রোটোকলে সার্ভার থেকে পাঠানো প্রতিটি মেসেজ বা ফ্রেমের একটি সুনির্দিষ্ট টেক্সট স্ট্রাকচার থাকে:
id: 101
event: tool-start
data: {"type":"tool-start","toolName":"inspectLiveGitHubIssue","timestamp":1762014420000}
লক্ষ্য করুন, প্রতিটি ফ্রেমের শেষে দুটি নিউলাইন (\n\n) থাকে যা একটি ইভেন্টকে পরের ইভেন্ট থেকে আলাদা করে। আর যখন আমাদের এজেন্ট ব্যাকএন্ডে কোনো ভারী টুল রান করতে থাকে, তখন আমাদের সার্ভার প্রতি ৩ সেকেন্ড পরপর একটি হালকা event: heartbeat সিগন্যাল পাঠাতে থাকে—যাতে মাঝখানের কোনো রাউটার বা প্রক্সি কানেকশনটি কখনোই বন্ধ না করে।
নিচের সিকোয়েন্স ডায়াগ্রামে দেখুন আমাদের হার্টবিট-যুক্ত SSE স্ট্রিমিং আর্কিটেকচার কীভাবে কাজ করে:
sequenceDiagram
participant Client as React Fetch SSE Reader
participant Route as Next.js /api/agent/sse
participant Heartbeat as 3s Keep-Alive Timer
participant Engine as Agent Stream + Tools
Client->>Route: POST messages[] (Accept: text/event-stream)
Route-->>Client: HTTP 200 (X-Accel-Buffering: no, no-transform)
Route->>Heartbeat: ৩০০০ মিলিসেকেন্ডের টাইমার শুরু
Route->>Engine: মাল্টি-স্টেপ streamText() শুরু
Engine-->>Route: টুল কল (inspectLiveGitHubIssue)
Route-->>Client: event: tool-start (id: 1)
Heartbeat-->>Client: event: heartbeat (৪ সেকেন্ড API অপেক্ষায় কানেকশন সচল রাখে)
Engine-->>Route: টুলের JSON ফলাফল তৈরি
Route-->>Client: event: tool-result (id: 2)
Engine-->>Route: টেক্সট টোকেন ডেল্টা
Route-->>Client: event: text-delta (id: 3..N)
Engine-->>Route: জেনারেশন সম্পন্ন (Usage মেট্রিক্স)
Route->>Heartbeat: টাইমার বন্ধ (clearInterval)
Route-->>Client: event: done ও স্ট্রিম সমাপ্তআর্কিটেকচার ফ্লো সামারি (Architecture Flow Summary): ফ্রন্টএন্ড ক্লায়েন্ট /api/agent/sse রাউটে POST রিকোয়েস্ট পাঠালে সার্ভার অ্যান্টি-বাফারিং হেডারসহ (X-Accel-Buffering: no) কানেকশন ওপেন করে এবং ৩ সেকেন্ডের একটি হার্টবিট টাইমার চালু করে। এজেন্ট যখন টুল চালায় এবং টেক্সট তৈরি করে, সার্ভার তখন ক্রমিক নম্বরসহ (id) আলাদা আলাদা ইভেন্ট (tool-start, heartbeat, tool-result, text-delta, done) পাঠাতে থাকে এবং ক্লায়েন্ট সাইডে \n\n বাফার পার্সার সেগুলোকে নিরাপদে একত্রিত করে।
ধাপ ১: টাইপ-সেফ SSE প্রোটোকল এবং হার্টবিট স্ট্রিম কন্ট্রোলার তৈরি (sse-protocol.ts)
চলুন প্রথমে src/lib/agent/sse-protocol.ts ফাইলটি তৈরি করি। ২১তম পর্বে আমরা যেভাবে Discriminated Union ব্যবহার করেছিলাম, এখানেও ঠিক সেভাবেই প্রতিটি SSE ইভেন্টের জন্য AgentSSEPayload টাইপ এবং createReliableSSEStream হেল্পার ফাংশন তৈরি করব:
// src/lib/agent/sse-protocol.ts
export type AgentSSEPayload =
| {
type: 'phase';
phase: 'routing' | 'synthesizing';
detail: string;
timestamp: number;
}
| {
type: 'tool-start';
toolCallId: string;
toolName: string;
args: unknown;
timestamp: number;
}
| {
type: 'tool-result';
toolCallId: string;
toolName: string;
result: unknown;
timestamp: number;
}
| {
type: 'text-delta';
delta: string;
}
| {
type: 'heartbeat';
timestamp: number;
}
| {
type: 'error';
message: string;
retryable: boolean;
}
| {
type: 'done';
modelUsed: string;
promptTokens: number;
completionTokens: number;
stepCount: number;
latencyMs: number;
};
export interface SSEStreamWriter {
sendEvent: (payload: AgentSSEPayload) => void;
close: () => void;
}
export function formatSSEFrame(id: number, payload: AgentSSEPayload): string {
const serialized = JSON.stringify(payload);
return `id: ${id}\nevent: ${payload.type}\ndata: ${serialized}\n\n`;
}
export function createReliableSSEStream(
abortSignal: AbortSignal,
executor: (writer: SSEStreamWriter) => Promise<void>,
heartbeatIntervalMs = 3000
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let eventCounter = 0;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let isClosed = false;
function cleanupTimer() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
return new ReadableStream<Uint8Array>({
async start(controller) {
const writer: SSEStreamWriter = {
sendEvent(payload: AgentSSEPayload) {
if (isClosed || abortSignal.aborted) return;
eventCounter += 1;
const frame = formatSSEFrame(eventCounter, payload);
controller.enqueue(encoder.encode(frame));
},
close() {
if (isClosed) return;
isClosed = true;
cleanupTimer();
try {
controller.close();
} catch {
// ক্লায়েন্ট কানেকশন আগেই বন্ধ হয়ে গেলে ইগনোর করবে
}
},
};
abortSignal.addEventListener('abort', () => {
writer.close();
});
heartbeatTimer = setInterval(() => {
writer.sendEvent({
type: 'heartbeat',
timestamp: Date.now(),
});
}, heartbeatIntervalMs);
try {
await executor(writer);
} catch (err) {
const message =
err instanceof Error ? err.message : 'Unhandled SSE stream failure';
writer.sendEvent({
type: 'error',
message,
retryable: true,
});
} finally {
writer.close();
}
},
cancel() {
isClosed = true;
cleanupTimer();
},
});
}
এই ফাইলটিতে তিনটি গুরুত্বপূর্ণ প্রোডাকশন মেকানিজম কাজ করছে:
- স্বয়ংক্রিয় ৩-সেকেন্ডের Heartbeat (
heartbeatTimer): এজেন্ট যখন কোনো ধীরগতির API বা ডেটাবেস কলের জন্য অপেক্ষা করে, তখন প্রতি ৩ সেকেন্ড অন্তর এই টাইমারটি একটি ছোটevent: heartbeatফ্রেম পাঠায়—যাতে কানেকশন কখনোই আইডল (Idle) হয়ে কেটে না যায়। - মেমোরি লিক রোধ (
cleanupTimerওabortSignal): ইউজার যদি ব্রাউজার ট্যাব বন্ধ করে দেন বাStopবাটনে চাপ দেন, সাথে সাথে টাইমারটি ক্লিয়ার হয়ে স্ট্রিম বন্ধ হয়ে যায়। - স্ট্রিম চলাকালীন এরর হ্যান্ডলিং: স্ট্রিমিং শুরু হওয়ার পর (অর্থাৎ
HTTP 200চলে যাওয়ার পর) মাঝপথে কোনো এরর হলে কিন্তু আর স্ট্যাটাস কোড পাল্টে500করা যায় না। তাই আমাদেরcatchব্লকটি সরাসরি স্ট্রিমের ভেতরেই একটিevent: errorফ্রেম পাঠিয়ে দেয়, যাতে ফ্রন্টএন্ড সাথে সাথে ইউজারকে সতর্ক করতে পারে।
ধাপ ২: Next.js SSE Route Handler তৈরি করা (src/app/api/agent/sse/route.ts)
এবার চলুন src/app/api/agent/sse/route.ts ফাইলটি তৈরি করি। এখানে আমরা আমাদের createAgentTextStream ইঞ্জিনের fullStream লুপ থেকে প্রতিটি টুল কল, টুল রেজাল্ট এবং টেক্সট ডেল্টা ধরে আমাদের কাস্টম SSE ফ্রেমে রূপান্তর করব:
// src/app/api/agent/sse/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { defaultDevPulseConfig } from '@/lib/agent/blueprint';
import { type ChatMessage } from '@/lib/agent/llm-client';
import { createAgentTextStream } from '@/lib/agent/stream-client';
import { createReliableSSEStream } from '@/lib/agent/sse-protocol';
export const maxDuration = 60;
export const dynamic = 'force-dynamic';
const sseRequestSchema = z.object({
sessionId: z.string().min(8),
modelId: z
.enum(['gpt-4o', 'claude-3-5-sonnet-latest'])
.default('claude-3-5-sonnet-latest'),
messages: z
.array(
z.object({
role: z.enum(['user', 'assistant']),
content: z.string().trim().min(1).max(4000),
})
)
.min(1),
});
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
const parsed = sseRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid SSE request payload.' },
{ status: 400 }
);
}
const { messages, modelId } = parsed.data;
const startTime = Date.now();
const stream = createReliableSSEStream(req.signal, async (writer) => {
writer.sendEvent({
type: 'phase',
phase: 'routing',
detail: `Connected to ${modelId} SSE stream...`,
timestamp: Date.now(),
});
const result = createAgentTextStream(
messages as ChatMessage[],
{
...defaultDevPulseConfig,
modelId,
}
);
let stepCount = 1;
for await (const part of result.fullStream) {
if (req.signal.aborted) break;
switch (part.type) {
case 'tool-call': {
writer.sendEvent({
type: 'tool-start',
toolCallId: part.toolCallId,
toolName: part.toolName,
args: part.args,
timestamp: Date.now(),
});
break;
}
case 'tool-result': {
writer.sendEvent({
type: 'tool-result',
toolCallId: part.toolCallId,
toolName: part.toolName,
result: part.result,
timestamp: Date.now(),
});
writer.sendEvent({
type: 'phase',
phase: 'synthesizing',
detail: `Completed ${part.toolName}. Synthesizing observation...`,
timestamp: Date.now(),
});
stepCount += 1;
break;
}
case 'text-delta': {
writer.sendEvent({
type: 'text-delta',
delta: part.textDelta,
});
break;
}
case 'error': {
const errMsg =
part.error instanceof Error
? part.error.message
: 'Model stream error occurred.';
writer.sendEvent({
type: 'error',
message: errMsg,
retryable: true,
});
break;
}
case 'finish': {
writer.sendEvent({
type: 'done',
modelUsed: modelId,
promptTokens: part.usage.promptTokens,
completionTokens: part.usage.completionTokens,
stepCount,
latencyMs: Date.now() - startTime,
});
break;
}
}
}
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
ফাইলের একদম নিচে দেওয়া ৪টি HTTP Header খেয়াল করুন:
Cache-Control: no-cache, no-transform: এখানেno-transformথাকাটা অত্যন্ত জরুরি। এটি না দিলে অনেক CDN বা প্রক্সি সার্ভার ডেটা স্ট্রিমের ওপর Gzip কম্প্রেশন চালাতে গিয়ে ছোট চাঙ্কগুলোকে আটকে রাখে।X-Accel-Buffering: no: আপনি যখন নিজের VPS বা Docker-এ Nginx রিভার্স প্রক্সির পেছনে এই অ্যাপ চালাবেন, তখন এই একটি হেডারই Nginx-কে বাধ্য করবে কোনো বাফারিং না করে প্রতিটি শব্দ সাথে সাথে ব্রাউজারে পাঠিয়ে দিতে।
ধাপ ৩: React-এ বাফার-সেফ SSE Reader তৈরি করা (ReliableSSEChat.tsx)
অনেকের মনে প্রশ্ন আসতে পারে—আমরা ব্রাউজারের ডিফল্ট new EventSource('/api/agent/sse') ব্যবহার করছি না কেন? কারণ ব্রাউজারের নেটিভ EventSource শুধুমাত্র GET রিকোয়েস্ট সাপোর্ট করে! তাতে আপনি POST বডিতে আমাদের ১০-টার্নের messages[] অ্যারে বা কাস্টম হেডার পাঠাতে পারবেন না।
তাই প্রোডাকশন অ্যাপে fetch()-এর সাথে ReadableStreamDefaultReader এবং একটি \n\n লাইন-বাফার পার্সার ব্যবহার করা হয়। চলুন src/components/ReliableSSEChat.tsx তৈরি করি:
// src/components/ReliableSSEChat.tsx
'use client';
import { useRef, useState } from 'react';
import { type AgentSSEPayload } from '@/lib/agent/sse-protocol';
export default function ReliableSSEChat() {
const [prompt, setPrompt] = useState(
'Inspect vercel/ai issue #402 and check our rate-limit docs for burst rules.'
);
const [answer, setAnswer] = useState('');
const [statusText, setStatusText] = useState('Idle');
const [toolLogs, setToolLogs] = useState<string[]>([]);
const [lastHeartbeat, setLastHeartbeat] = useState<number | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
function handleIncomingSSEPayload(payload: AgentSSEPayload) {
switch (payload.type) {
case 'phase':
setStatusText(payload.detail);
break;
case 'tool-start':
setStatusText(`Running tool: ${payload.toolName}...`);
setToolLogs((prev) => [
...prev,
`⚡ Started ${payload.toolName} (${JSON.stringify(payload.args)})`,
]);
break;
case 'tool-result':
setToolLogs((prev) => [
...prev,
`✓ Finished ${payload.toolName}`,
]);
break;
case 'text-delta':
setStatusText('Streaming answer...');
setAnswer((prev) => prev + payload.delta);
break;
case 'heartbeat':
setLastHeartbeat(payload.timestamp);
break;
case 'error':
setStatusText(`Error: ${payload.message}`);
break;
case 'done':
setStatusText(
`Completed in ${payload.latencyMs}ms (${payload.promptTokens} in / ${payload.completionTokens} out tokens)`
);
break;
}
}
async function startSSEStream(e: React.FormEvent) {
e.preventDefault();
if (!prompt.trim() || isStreaming) return;
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsStreaming(true);
setAnswer('');
setToolLogs([]);
setStatusText('Opening SSE connection...');
try {
const response = await fetch('/api/agent/sse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: 'sse-reliable-session-01',
modelId: 'claude-3-5-sonnet-latest',
messages: [{ role: 'user', content: prompt }],
}),
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}: Failed to open SSE stream.`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let rawBuffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
rawBuffer += decoder.decode(value, { stream: true });
const frames = rawBuffer.split('\n\n');
rawBuffer = frames.pop() ?? '';
for (const frame of frames) {
const dataLine = frame
.split('\n')
.find((line) => line.startsWith('data: '));
if (!dataLine) continue;
const jsonStr = dataLine.slice(6).trim();
try {
const payload = JSON.parse(jsonStr) as AgentSSEPayload;
handleIncomingSSEPayload(payload);
} catch {
// অসম্পূর্ণ বা ভাঙা ফ্রেম ইগনোর করা হচ্ছে
}
}
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
setStatusText('Stream cancelled by user.');
} else {
setStatusText(
err instanceof Error ? err.message : 'Stream connection lost.'
);
}
} finally {
setIsStreaming(false);
}
}
return (
<div className='max-w-3xl mx-auto p-6 bg-slate-900 text-slate-100 rounded-xl border border-slate-800 space-y-4'>
<div className='flex items-center justify-between border-b border-slate-800 pb-3'>
<div>
<h2 className='text-base font-semibold text-cyan-400'>
DevPulse Resilient SSE Transport
</h2>
<p className='text-xs text-slate-400'>{statusText}</p>
</div>
{lastHeartbeat && (
<span className='text-[11px] font-mono text-emerald-400 bg-emerald-950/50 px-2.5 py-1 rounded border border-emerald-800/60'>
♥ Heartbeat Active
</span>
)}
</div>
<form onSubmit={startSSEStream} className='flex gap-2'>
<input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className='flex-1 px-3.5 py-2 rounded-lg bg-slate-950 border border-slate-700 text-sm'
/>
{isStreaming ? (
<button
type='button'
onClick={() => abortControllerRef.current?.abort()}
className='px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-sm font-semibold'
>
Abort SSE
</button>
) : (
<button
type='submit'
className='px-4 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-sm font-semibold'
>
Stream via SSE
</button>
)}
</form>
{toolLogs.length > 0 && (
<div className='p-3 rounded-lg bg-slate-950 border border-purple-900/50 space-y-1 font-mono text-xs text-purple-300'>
{toolLogs.map((log, i) => (
<div key={i}>{log}</div>
))}
</div>
)}
<div className='p-4 min-h-[140px] rounded-lg bg-slate-950 border border-slate-800 text-sm whitespace-pre-wrap leading-relaxed'>
{answer || 'SSE frames will assemble here...'}
</div>
</div>
);
}
উপরের কোডের while (true) লুপের ভেতরের এই তিনটি লাইনই হলো ক্লায়েন্ট-সাইড SSE পার্সিংয়ের প্রাণভোমরা:
rawBuffer += decoder.decode(value, { stream: true });
const frames = rawBuffer.split('\n\n');
rawBuffer = frames.pop() ?? '';
কেন আমরা frames.pop() করে শেষের অংশটি আবার rawBuffer-এ রেখে দিচ্ছি? কারণ নেটওয়ার্ক দিয়ে ডেটা আসার সময় কোনো একটি বড় JSON ফ্রেমের অর্ধেক অংশ যদি প্রথম প্যাকেটে আসে এবং বাকি অর্ধেক পরের প্যাকেটে আসে, তবে split('\n\n') করার পর অ্যারের একদম শেষের এলিমেন্টটি থাকবে অসম্পূর্ণ! সেটিকে frames.pop() করে rawBuffer-এ রেখে দেওয়ার ফলে পরের বার reader.read() হলে বাকি অংশটুকু এসে তার সাথে জোড়া লাগে এবং একটিও শব্দ হারায় না।

আমার নিজের একটি ডিবাগিং অভিজ্ঞতা: এক প্যাকেটে দুটি SSE ফ্রেম এবং বাংলা ইউনিকোড ভেঙে যাওয়ার রহস্য!
আমি যখন আমার LocalDrop Pro প্রজেক্টে লোকাল নেটওয়ার্কে ডেটা স্ট্রিমিং নিয়ে কাজ করছিলাম এবং পরবর্তীতে কাস্টম LLM স্ট্রিমিং রাউট বানিয়ে টেস্ট করছিলাম, তখন এমন দুটি বাগের মুখোমুখি হয়েছিলাম যা লোকালহোস্টে কখনোই দেখা দেয়নি কিন্তু লাইভ নেটওয়ার্কে বারবার ঘটছিল।
প্রথমবার আমি যখন ক্লায়েন্ট সাইডে স্ট্রিম রিডার লিখি, তখন খুব সরল মনে ভেবেছিলাম—সার্ভারে একবার controller.enqueue() কল করা মানে ব্রাউজারেও নিশ্চয়ই একবার reader.read() কল হবে! তাই আমি সরাসরি প্রাপ্ত চাঙ্ক থেকে data: কেটে ফেলে JSON.parse() চালাচ্ছিলাম। কিন্তু লাইভ সার্ভারে ডিপ্লয় করার পর দেখলাম প্রতি ৪-৫ লাইন পরপর কিছু শব্দ উধাও হয়ে যাচ্ছে এবং কনসোলে SyntaxError: Unexpected non-whitespace character after JSON এরর আসছে। ডিবাগ করে দেখলাম, মডেল যখন খুব দ্রুত ছোট ছোট শব্দ স্ট্রিম করে, তখন নেটওয়ার্ক লেয়ারে (TCP Coalescing) পরপর দুটি বা তিনটি data: {...}\n\n ফ্রেম একসাথে মিলে একটি সিঙ্গেল চাঙ্ক হিসেবে ব্রাউজারে ঢোকে! তখন একসাথে দুটি JSON অবজেক্টকে একবারে JSON.parse() করতে গিয়েই পার্সার ক্র্যাশ করছিল।
দ্বিতীয় সমস্যাটি হয়েছিল বাংলা টেক্সট স্ট্রিম করার সময়। বাংলা ইউনিকোডের একেকটি অক্ষর UTF-8-এ ৩ বাইট জায়গা নেয়। আমি শুরুতে decoder.decode(value) কল করার সময় { stream: true } অপশনটি দিইনি। ফলে যখনই কোনো ৩-বাইটের বাংলা অক্ষরের প্রথম ২ বাইট এক প্যাকেটে আর শেষ ১ বাইট পরের প্যাকেটে আসছিল, তখনই ব্রাউজারে বাংলা অক্ষরের জায়গায় কালো প্রশ্নবোধক চিহ্ন () চলে আসছিল! decoder.decode(value, { stream: true })এবংrawBuffer.split('\n\n')+frames.pop()` বসানোর সাথে সাথেই এই দুটি বাগ চিরতরে দূর হয়ে যায়।
SSE দিয়ে স্ট্রিমিং বানানোর সময় যে ৪টি ভুল এড়িয়ে চলবেন
পরের পর্বে (পর্ব ২৪) Supabase-এ এজেন্টের চ্যাট হিস্ট্রি সেভ করার গাইডে যাওয়ার আগে নিশ্চিত হয়ে নিন আপনার কোডে যেন এই ৪টি ভুল না থাকে:
১. বাফার ছাড়া সরাসরি reader.read() চাঙ্কের ওপর JSON.parse চালানো
কখনোই ধরে নেবেন না যে একটি নেটওয়ার্ক রিড মানেই একটি পূর্ণাঙ্গ SSE ইভেন্ট। সবসময় একটি স্ট্রিং বাফারে (rawBuffer) ডেটা জমা করবেন, \n\n দিয়ে ভাগ করবেন এবং শেষের অসম্পূর্ণ অংশটুকু frames.pop() দিয়ে পরের রিডের জন্য রেখে দেবেন।
২. TextDecoder.decode()-এ { stream: true } না দেওয়া
বিশেষ করে বাংলা ভাষা, ইমোজি বা যেকোনো মাল্টি-বাইট ইউনিকোড স্ট্রিম করার সময় decoder.decode(value, { stream: true }) দেওয়া বাধ্যতামূলক। এটি না দিলে প্যাকেটের সীমানায় পড়া বাংলা অক্ষরগুলো ভেঙে গার্বেজ ক্যারেক্টার হয়ে যাবে।
৩. no-transform এবং X-Accel-Buffering: no হেডার ভুলে যাওয়া
শুধুমাত্র Content-Type: text/event-stream দিলেই সব প্রক্সি বাফারিং বন্ধ করে না। Nginx বা ক্লাউড প্রক্সির পেছনে রিয়েল-টাইম স্ট্রিমিং পেতে হলে 'Cache-Control': 'no-cache, no-transform' এবং 'X-Accel-Buffering': 'no' অবশ্যই রেসপন্স হেডারে যুক্ত করবেন।
৪. স্ট্রিম বন্ধ হওয়ার পরও setInterval হার্টবিট চালু থাকা
আপনি যদি ReadableStream-এর ভেতরে ৩ সেকেন্ডের হার্টবিট টাইমার চালু করেন কিন্তু cancel() এবং abortSignal লিসেনারে clearInterval() না করেন, তবে ইউজার চলে যাওয়ার পরও সার্ভারে মেমোরি লিক হতে থাকবে।
প্রায়শই জিজ্ঞাসিত প্রশ্নাবলী (FAQ)
AI Agent স্ট্রিমিংয়ের জন্য WebSockets ব্যবহার না করে কেন Server-Sent Events (SSE) ব্যবহার করা ভালো?
AI Agent-এর যোগাযোগ মূলত একমুখী স্ট্রিমিং—ইউজার একটি ছোট প্রশ্ন (POST) পাঠান, আর সার্ভার তার উত্তরে শত শত টোকেন এবং টুল আপডেট স্ট্রিম করে। SSE সরাসরি সাধারণ HTTP কানেকশনের ওপর কাজ করে, ফলে এর জন্য আলাদা কোনো স্টেটফুল WebSocket সার্ভার লাগে না, Next.js-এর সার্ভারলেস রাউট ও মিডলওয়্যারের সাথে সহজেই মিলে যায় এবং কর্পোরেট ফায়ারওয়ালে কখনোই ব্লক হয় না।
ব্রাউজারের বিল্ট-ইন EventSource API ব্যবহার না করে আমরা কেন fetch() ও ReadableStream ব্যবহার করলাম?
ব্রাউজারের নেটিভ new EventSource() শুধুমাত্র GET রিকোয়েস্ট সাপোর্ট করে। এতে আপনি কোনো JSON body (যেমন আমাদের চ্যাট হিস্ট্রি অ্যারে messages[]) বা কাস্টম হেডার পাঠাতে পারবেন না। কিন্তু fetch('/api/agent/sse', { method: 'POST' }) এবং ReadableStream ব্যবহার করলে আপনি একই সাথে POST বডি পাঠানোর সুবিধা এবং স্ট্যান্ডার্ড text/event-stream প্রোটোকলের নির্ভরযোগ্যতা—দুটোই পেয়ে যাচ্ছেন।
কখন আমরা Vercel AI SDK-এর useChat ব্যবহার করব আর কখন এই কাস্টম SSE রাউট ব্যবহার করব?
যখন আপনি শুধুমাত্র Next.js ও React-এর ভেতরে দ্রুত একটি চ্যাট ইন্টারফেস বানাচ্ছেন, তখন useChat হুক ব্যবহার করাই সবচেয়ে সহজ। কিন্তু যখন আপনার এজেন্টে দীর্ঘ সময় ধরে চলা এক্সটার্নাল টুল থাকে (যেখানে কানেকশন ধরে রাখতে ৩ সেকেন্ডের Heartbeat দরকার), কিংবা আপনি কাস্টম প্রক্সি কন্ট্রোল ও স্ট্যান্ডার্ড ইভেন্ট স্ট্রিম চান যা ওয়েবের পাশাপাশি মোবাইল অ্যাপ বা ব্রাউজার এক্সটেনশন থেকেও ব্যবহার করা যাবে—তখন এই কাস্টম SSE পাইপলাইনটি অপরিহার্য।
কাস্টম ওয়েব অ্যাপ বা এআই অটোমেশন বানাতে চান?
আমি স্টার্টআপ ও আধুনিক ব্যবসার জন্য স্কেলেবল ফুল-স্ট্যাক ওয়েব অ্যাপ্লিকেশন (Next.js, React, Node, PostgreSQL) এবং ইন্টেলিজেন্ট এআই এজেন্ট সলিউশন তৈরি করি। আপনার প্রজেক্টের আইডিয়া নিয়ে কথা বলা যাক!
আর্টিকেলটি কি আপনার ভালো লেগেছে?
তন্ময়ের কাজকে সাপোর্ট করতে তালি (Clap) দিয়ে উৎসাহিত করুন!
জাহিদ হাসান তন্ময়
সফটওয়্যার ডেভেলপার
MERN ফুল-স্ট্যাক ডেভেলপার এবং AI এজেন্ট ডেভেলপার, ঢাকা, বাংলাদেশ। ওয়েব ডেভেলপমেন্ট, রিঅ্যাক্ট, লারাভেল এবং আমার লার্নিং জার্নি নিয়ে লিখছি।