← 블로그

콘솔에서의 서버 렌더링

Text.js 0.4는 서버에서 페이지를 렌더링하고 프레임을 셀 변경분으로 콘솔에 스트리밍할 수 있어요. 정적 라우트는 첫 프레임을 HTML에 프리렌더링해요.

Adam

Text.js 0.4에서는 페이지를 렌더링할 두 번째 장소가 추가됐어요. 기본적으로 페이지의 React 트리는 브라우저 탭에서 실행돼요. runtime = "server"를 내보내는 페이지는 대신 서버에서 실행되고, 브라우저는 해당 프레임을 받아요.

app/status/page.tsxtsx
import { Suspense, type ReactNode } from "react";import os from "node:os";import { Box, Text } from "textjs";export const runtime = "server";async function Load(): Promise<ReactNode> {  const [one, five] = os.loadavg();  return <Text>{`load ${one.toFixed(2)} ${five.toFixed(2)}`}</Text>;}export default function StatusPage(): ReactNode {  return (    <Box flexDirection="column">      <Text bold>Status</Text>      <Suspense fallback={<Text color="gray">Reading host…</Text>}>        <Load />      </Suspense>    </Box>  );}

Load는 async function 컴포넌트예요. 서버에서는 프라미스가 완료될 때까지 가장 가까운 <Suspense> 또는 loading.tsx에서 대기하며, 파일, 데이터베이스, node:* 모듈을 직접 읽을 수 있어요. 비동기 컴포넌트는 데이터를 불러오며 훅을 사용할 수 없어요. 주변의 일반 컴포넌트는 훅, 포커스, useInput, <Link>, useRouter()를 그대로 사용하며, 이 기능들은 두 런타임에서 똑같이 동작해요.

브라우저가 다운로드하는 항목

서버에서 렌더링된 페이지는 React, Yoga, 앱의 라우트를 전송하지 않아요. 브라우저는 gzip 기준 약 21kB인 프레임 플레이어를 불러오며, 이 플레이어는 CellBuffer와 클라이언트 렌더러가 사용하는 것과 동일한 CssConsoleDriver를 담고 있어요. 프레임 속도도 동일한 15fps로 맞추므로, 서버에서 렌더링된 프레임은 클라이언트와 같은 console.log 호출을 생성해요.

프로토콜

각 탭은 서버에 React 루트, 메모리 내 라우터 기록, 셀 버퍼로 구성된 별도 세션을 열어요. 하나의 엔드포인트가 양방향 통신을 처리해요:

browser tab ──(GET /__textjs/live, server-sent events)──▶ frame deltasbrowser tab ──(POST /__textjs/live, JSON)──────────────▶ keys, paste, wheel, resize, focus, Back/Forward

서버는 해당 연결이 마지막으로 받은 프레임 이후 해시가 바뀐 행만 보내요. 각 행은 덧붙이기 전용 스타일 테이블을 참조하는 텍스트 구간으로 전송돼요. 폭이 넓은 문자소, 이모지 클러스터, 반투명 색상도 그대로 전달돼요.

프레임 간격은 최소 66ms이며, 각 프레임은 소켓이 마지막으로 받아들인 내용과 비교해 차이를 계산해요. 느린 연결에서 전송 버퍼가 더 이상 비워지지 않으면 중간 상태를 건너뛰고, 다음 프레임은 최신 상태로 바로 넘어가요.

스트림이 끊기면 플레이어는 x-textjs-live 헤더에 세션 ID를 넣어 다시 연결하고, 상태를 유지한 채 새 키프레임부터 같은 세션을 재개해요. 세션 ID는 메모리에 보관하는 192비트 무작위 값이며, URL에는 넣지 않아요. 연결이 없는 세션은 렌더링을 멈추고 30초 후 마운트 해제돼요.

두 엔드포인트 모두 교차 사이트 요청을 거부하며, 모든 입력 이벤트는 고정된 스키마로 검증해요. 세션은 POST당 최대 64개, 초당 약 240개의 이벤트를 받아들여요.

비동기 컴포넌트와 데이터

각 비동기 컴포넌트의 프라미스는 컴포넌트와 props를 키로 삼아 세션별로 캐시되므로, 렌더링을 재시도할 때 이미 진행 중인 요청을 재사용해요. 라우트가 바뀌거나 router.refresh()를 호출하면 캐시가 비워져요:

const router = useRouter();useInput((e) => e.input === "r" && router.refresh());

서버 전용 모듈의 이름은 *.server.ts로 지정해요. 브라우저로 전송되는 코드가 이 모듈이나 node:* 내장 모듈을 가져오면 빌드가 실패하고, 가져온 파일의 이름을 알려 줘요.

부분 프리렌더링

부분 프리렌더링은 서버 없이 동작해요. 이제 text build는 모든 정적 라우트를 한 번씩 렌더링하고 첫 프레임을 작은 클래식 <script>로 라우트의 HTML에 인라인으로 넣으므로, 모듈이 로드되기 전에 콘솔이 그려져요.

셸은 동기적으로 렌더링되는 모든 내용이에요. 아직 대기 중인 <Suspense> 경계는 비동기 컴포넌트를 포함해 폴백을 표시하며, 셸이 렌더링되는 동안에는 fetch()가 완료되지 않아요.

JavaScript가 시작되면 클라이언트 렌더러나 프레임 플레이어가 실제 프레임을 스트리밍해요. 0.4에서는 프레임을 지우는 대신 덧붙이기도 하므로, 하이드레이션하는 렌더러는 initialClear: false로 시작해 셸 아래에 로그를 남기며, 커튼이 같은 DevTools 페인트 안에서 셸을 화면 밖으로 밀어내요. 콘솔은 셸이 정확히 한 번만 지워요.

 ✓ Prerendering console shells (5/5) · 1 server-rendered, frame player 56.5 kB (21.3 kB gzip)

빌드 시에는 방문자의 콘솔 크기를 알 수 없으므로, 셸은 설정된 viewport 크기로 렌더링되며 기본값은 120 × 36이에요. viewport.responsive를 켜면 첫 라이브 프레임이 도킹된 콘솔에 맞춰 크기를 조정해요. 동적 라우트는 셸을 렌더링하지 않으며, text.config.ts에서 ppr: false를 설정하면 프리렌더링이 꺼져요.

런타임 선택

클라이언트 런타임은 계속 기본값으로 유지돼요. 키 입력은 같은 태스크에서 그려지고, 앱은 어떤 정적 호스트에도 배포할 수 있어요. 서버 런타임은 비공개 데이터를 읽는 대시보드와 관리 화면, 의존성을 브라우저 번들에 넣고 싶지 않은 라우트에 적합해요. 하나의 앱에서 둘을 섞어 쓸 수 있으며, 두 런타임 사이를 이동하면 대상 URL을 새 문서로 불러와요.

전체 레퍼런스는 서버 렌더링에서 확인하세요.