C.W.K.
Stream
Lesson 03 of 04 · published

fetch로 TypeScript streaming

~22 min · streaming, typescript

Level 0Downloader
0 XP0/41 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

Async generator 패턴

Node 20+랑 모던 브라우저에서 fetch().bodyReadableStream + TextDecoder + 수동 newline-buffer가 Ollama NDJSON 소비하는 canonical 방법. async function* generator로 감싸면 stream을 for await로 iterate 가능해.

Buffer 패턴 (절대 skip 금지)

Network chunk는 JSON 줄 경계에 안 맞아. 한 번의 read()가 이렇게 반환할 수 있음:

  • '{"a":1,"b":2}\n{"c":3,' — 마지막 줄 미완성
  • '4}\n{"e":5}\n' — 첫 부분이 이전 줄 완성

Buffer에 누적, \n로 split, 완성된 줄 다 parse, 부분 tail은 buffer에 유지 — 이게 TypeScript Ollama client에서 가장 버그 많은 부분.

Code

Buffer 제대로 처리하는 async generator·typescript
type Msg = { role: "system" | "user" | "assistant" | "tool"; content: string };

async function* streamChat(
  model: string,
  messages: Msg[],
  baseUrl = "http://localhost:11434",
): AsyncGenerator<{ content?: string; done?: boolean; raw?: any }> {
  const r = await fetch(`${baseUrl}/api/chat`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model, messages, stream: true }),
  });
  if (!r.ok || !r.body) throw new Error(`Ollama ${r.status}`);

  const reader = r.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? ""; // 미완성 tail 유지

    for (const line of lines) {
      if (!line.trim()) continue;
      const chunk = JSON.parse(line);
      if (chunk.done) {
        yield { done: true, raw: chunk };
        return;
      }
      yield { content: chunk.message?.content ?? "", raw: chunk };
    }
  }
}

// 사용
for await (const tok of streamChat(
  "qwen2.5:7b",
  [{ role: "user", content: "Explain GGUF in 3 sentences." }],
)) {
  if (tok.done) {
    const r = tok.raw;
    const tps = r.eval_count / (r.eval_duration / 1e9);
    console.log(`\n[${r.eval_count} tokens @ ${tps.toFixed(1)} tok/s]`);
    break;
  }
  process.stdout.write(tok.content ?? "");
}
브라우저 버전 (같은 골격)·typescript
// CORS 설정했거나 자체 backend 통해 proxy하면 같은 코드 브라우저에서 동작.
// 차이점: process.stdout 대신 textContent 쓰고 DOM 노드에 append.

async function streamIntoDOM(target: HTMLElement, model: string, messages: Msg[]) {
  for await (const tok of streamChat(model, messages)) {
    if (tok.done) break;
    target.textContent += tok.content ?? "";
  }
}

External links

Exercise

Buffer 제대로 처리한 streamChat(model, messages) TypeScript 구현. 두 시나리오로 test: 짧은 답변 (network read 한 번에 stream 전체 커버)이랑 긴 답변 (read 여러 번). 둘 다 동작하고 부분 줄 손실 없는지 확인.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.