서버 렌더링과 부분 프리렌더링
runtime = "server": 서버에서 렌더링해 프레임으로 스트리밍하는 라우트, 프리렌더링된 콘솔 셸.
라우트의 React 트리를 서버에서 렌더링하고 프레임을 콘솔로 스트리밍해요. 모든 정적 라우트의 첫 프레임을 해당 HTML에 프리렌더링해요.
두 가지 런타임
모든 페이지는 두 위치 중 한 곳에서 렌더링돼요:
| 클라이언트 (기본값) | 서버 (runtime = "server") | |
|---|---|---|
| React 트리 실행 위치 | 브라우저 탭 | text dev / text start / bun 어댑터 (Bun 또는 Node) |
| 브라우저 다운로드 항목 | 전체 앱: React, Yoga WASM, 앱의 라우트 | 프레임 플레이어, gzip 기준 약 21kB |
| 데이터 접근 | 라우트 핸들러에 fetch() 호출 | 직접 접근: 데이터베이스, 파일, node:*, 비밀 정보 |
| 비동기 컴포넌트 | use() 또는 이펙트 사용 | async function 컴포넌트 지원 |
| 키 입력 지연 | 같은 태스크에서 동기 처리 | 서버 왕복 1회 |
| 정적 호스트에서 실행 | 가능 | 불가, text start 또는 bun dist/server.js 필요 |
두 런타임은 같은 프레임을 생성해요. 프레임 플레이어는 같은 CssConsoleDriver로 셀을 인코딩하고 같은 약 15fps 속도로 출력하므로, 서버에서 렌더링된 프레임은 클라이언트의 console.log 호출과 바이트 단위로 일치해요.
사용 설정
페이지에서 runtime을 내보내세요:
import { Suspense, type ReactNode } from "react";import os from "node:os";import { Box, Text } from "@textjs/app";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> );}runtime은 페이지마다 정적 문자열 내보내기로 읽어요(타입 주석, as const, 다시 내보내기는 허용되지만 계산된 값은 허용되지 않아요). 레이아웃, loading.tsx, error.tsx, not-found.tsx는 해당 페이지가 렌더링되는 곳에서 렌더링돼요: 클라이언트 페이지와 서버 페이지가 공유하는 레이아웃은 양쪽에서 실행되고 브라우저로 전송되지만, 서버 페이지만 포함하는 세그먼트의 레이아웃과 경계는 서버에 남아요. 훅, 포커스, useInput, <Link>, useRouter()는 클라이언트에서와 똑같이 동작해요.
런타임 선택 기준
다음에는 클라이언트 런타임(기본값)을 사용하세요:
- 편집기나 필터처럼 모든 키 입력을 같은 태스크에서 그려야 하는, 입력이 많은 UI.
- 정적 호스트나 CDN에 배포하는 앱.
- 오프라인에서도 작동하는 도구.
다음에는 서버 런타임을 사용하세요:
- 데이터베이스, 파일, 큐 또는 비공개 API를 읽는 대시보드와 관리 화면. 인증 정보는 서버에 남아요.
- 브라우저 번들에 넣고 싶지 않은 무거운 의존성(SDK, 파서, 구문 테이블)이 있는 라우트.
- 느린 네트워크에서도 빠르게 로드해야 하는 페이지. 브라우저는 전체 앱 대신 21kB(gzip) 플레이어를 다운로드해요.
하나의 앱에서 런타임을 섞어 쓸 수 있어요. 런타임 사이를 이동하면 대상 URL을 새 문서로 로드해요. 브라우저 번들은 각 서버 페이지를 스텁으로 대체하므로, 서버 모듈과 해당 모듈이 가져오는 항목은 클라이언트로 전송되지 않아요.
서버 전용 모듈의 이름은 *.server.ts로 지정하세요. 브라우저로 전송되는 코드(클라이언트 페이지 또는 클라이언트 페이지가 사용하는 레이아웃)가 *.server.* 모듈이나 node:* 내장 모듈을 가져오면, 빌드가 실패하고 가져온 모듈의 이름을 표시해요.
미들웨어는 문서를 보호하는 것과 같은 방식으로 서버 라우트를 보호해요: 세션이 열리기 전, 세션 내에서 이동할 때마다(router.push, 뒤로, 앞으로), 세션의 쿠키나 Authorization이 바뀔 때마다 대상 라우트에 실행돼요. 리디렉션, 거부 또는 재작성이 필요한 경우 브라우저가 대신 URL을 문서로 로드하고, 미들웨어가 해당 요청에 응답해요.
비동기 서버 컴포넌트
서버에서 모든 async function 컴포넌트는 프로미스가 이행될 때까지 가장 가까운 <Suspense> 또는 loading.tsx에서 렌더링을 일시 중단해요:
export const runtime = "server";async function Deployments({ team }: { team: string }) { const rows = await db.deployments.list({ team }); return <Table data={rows} columns={COLUMNS} />;}각 프로미스는 컴포넌트와 props를 키로 사용해 세션별로 캐시되므로, 렌더링을 재시도하면 이미 진행 중인 요청을 재사용해요. 라우트가 바뀌거나 router.refresh()를 호출하면 캐시가 지워져요. 입력 후 데이터 접근을 다시 실행하려면 router.refresh()를 호출하세요:
const router = useRouter();useInput((e) => e.input === "r" && router.refresh());비동기 컴포넌트에 전달하는 props는 직렬화할 수 있어야 해요: 문자열, 숫자, 일반 객체, 배열, Date, URL, Map, Set, 엘리먼트가 해당해요. 함수 props는 키에 포함되지 않아요(콜백 대신 데이터를 전달하세요). 각 세션은 최대 256개의 프로미스를 캐시해요. @textjs/app/server-components의 serverComponent(fn)으로 컴포넌트를 직접 감쌀 수도 있어요.
훅(useState, useInput, …)은 async function 컴포넌트 안에서 작동하지 않아요. 상호작용을 처리하려면 바깥이나 하위에 일반 컴포넌트를 렌더링하세요.
작동 방식
browser tab ──(GET /__textjs/live, server-sent events)──▶ frame deltasbrowser tab ──(POST /__textjs/live, JSON)──────────────▶ keys, paste, wheel, resize, focus, Back/Forward- 탭마다 하나의 세션. 페이지를 로드할 때마다 서버에 React 루트, 메모리 내 라우터 기록, 셀 버퍼로 구성된 별도의 세션이 열려요. 세션 ID는 192비트 무작위 값이며 브라우저는 메모리에만 보관해요.
- 셀 변경분. 서버는 해당 연결이 마지막으로 받은 프레임 이후 해시가 바뀐 행만 전송해요. 각 행은 덧붙이기 전용 스타일 테이블을 참조하는 연속된 텍스트로 전송되며, 폭이 넓은 문자소, 이모지 클러스터, 반투명 색상이 그대로 보존돼요.
- 백프레셔. 프레임 간격은 최소 66ms이며, 각 프레임은 소켓이 마지막으로 수락한 내용과 비교해 변경분을 구해요. 느린 연결이 데이터를 더 이상 내보내지 못하면 중간 상태를 건너뛰고 다음 프레임에서 바로 최신 상태로 넘어가요.
- 재연결. 스트림이 끊기면 플레이어는 URL이 아닌
x-textjs-live헤더에 세션 ID를 담아 재연결하고, 상태를 유지한 채 새 키프레임부터 같은 세션을 재개해요. 누락된 주소 표시줄 업데이트는 다시 적용해요. 연결이 없는 세션은 렌더링을 멈추고 30초 후 마운트 해제돼요. 서버에 해당 세션이 더 이상 없으면 플레이어가 새 세션을 열어요. - 기록. 뒤로와 앞으로는 양쪽 기록을 같은 항목 수만큼 이동하며, 서버의
router.back()도 브라우저 기록을 이동해요. - 보안. 두 엔드포인트 모두 교차 사이트 요청을 거부해요(
Sec-Fetch-Site와Origin사용.X-Forwarded-Host는server.trustProxy를 설정했을 때만 반영해요). 입력 POST에는 JSON 본문과x-textjs-live헤더가 필요해요. 모든 이벤트는 고정 스키마로 검증하며, 이동 대상은 같은 출처의 경로여야 해요.app/매니페스트에 있는 페이지만 렌더링할 수 있어요. 세션은 처음 열 때 사용한 쿠키에 연결돼요. - 제한. POST당 최대 64개 이벤트, 세션당 초당 약 240개 이벤트(초과 시
429), 본문 타임아웃 5초, 클라이언트 주소당 32개 세션, 프로세스당maxSessions제한이 적용돼요(연결이 끊긴 지 가장 오래된 세션부터 제거해요).
export default defineConfig({ live: { maxSessions: 512, reconnectGraceMs: 60_000 },});부분 프리렌더링
text build는 모든 정적 라우트를 설정된 viewport 크기로 한 번씩 렌더링하고, 첫 프레임을 작은 클래식 <script>로 라우트의 HTML에 인라인 삽입해요. 모듈이 로드되기 전에 콘솔에 그려져요:
- 정적 셸. 동기적으로 렌더링되는 모든 내용이에요. 아직 대기 중인 모든
<Suspense>경계는 폴백을 표시해요. 비동기 서버 컴포넌트(셸을 렌더링하는 동안에는 실행되지 않아요),use(), 지연 로드 모듈도 포함돼요. 셸을 렌더링하는 동안fetch()는 이행되지 않으므로, 이펙트에서 로드하는 데이터도 로딩 상태에 머물러요. - 동적 영역. JavaScript가 시작되면 클라이언트 렌더러나 프레임 플레이어가 실제 프레임을 스트리밍해요. 이 프레임은 셸 아래에 덧붙여지며, 하이드레이션 중인 프레임 자체의 커튼이 같은 DevTools 그리기에서 셸을 화면 밖으로 밀어내요. 셸은 그려진 시점을 기록하므로, 첫 하이드레이션 프레임은 DevTools의 느린 처리 경로로 들어가지 않고 콘솔을 지운 후의 대기 구간이 끝날 때까지 기다려요.
로드 중에는 셸만 콘솔을 지워요. 하이드레이션 렌더러는 덧붙이기 모드(initialClear: false)로 시작하므로 깜박임이 없고, 셸은 렌더링된 경로에서만 그려져요. 동적 라우트([id], force-dynamic)는 셸을 렌더링하지 않아요.
✓ Prerendering console shells (5/5) · 1 server-rendered, frame player 56.5 kB (21.3 kB gzip)끄려면 text.config.ts에 ppr: false를 설정하세요. 프리렌더링은 빌드 시점에 컴포넌트를 실행하므로 모듈 최상위 코드에서 브라우저 전용 전역 변수를 사용하지 마세요. 프리렌더링 중 예외를 던지거나 redirect() / notFound()를 호출하거나 이동하는 라우트에는 셸이 생성되지 않으며(오류 화면은 HTML에 포함되지 않아요), 빌드가 이유를 알려 줘요.
빌드는 방문자의 콘솔 크기를 알 수 없으므로 셸을 viewport.width × viewport.height(기본값 120 × 36)로 렌더링하고, 하이드레이션 프레임이 실제 콘솔 크기에 맞게 조정돼요. 셸은 인라인 <script>예요: 인라인 스크립트를 금지하는 콘텐츠 보안 정책을 사용한다면 해시로 허용하거나 ppr: false를 설정하세요.
배포
text build는 다음 파일을 생성해요:
| 파일 | 용도 |
|---|---|
index.html, <route>/index.html | 셸을 포함한 클라이언트 라우트. 모든 정적 호스트에서 실행 가능. |
assets/player-[hash].js | 프레임 플레이어. |
.text/server/live.mjs | 서버 라우트, App Router, 콘솔 렌더러. |
.text/pages/…, .text/live.html | 서버 라우트 문서. |
.text/ansi/… | curl 및 기타 터미널 클라이언트용 첫 프레임. |
text start와 bun 어댑터는 이 파일을 모두 제공해요. 서버 라우트에는 장시간 실행되는 프로세스가 필요해요: static, vercel, cloudflare 어댑터는 빌드를 실패시켜요(TX030).