רינדור בצד השרת ורינדור מראש חלקי

⁦runtime⁩ = "⁦server⁩": נתיבים שמרונדרים בשרת ומוזרמים כפריימים; מעטפות מסוף ברינדור מראש.

רינדור עץ React של נתיב בשרת והזרמת הפריימים שלו למסוף. רינדור מראש של הפריים הראשון של כל נתיב סטטי לתוך ה-HTML שלו.

שתי סביבות ריצה

כל עמוד מרונדר באחד משני מקומות:

לקוח (ברירת מחדל)שרת (runtime = "server")
עץ React רץ בתוךכרטיסיית הדפדפןtext dev / text start / מתאם bun (Bun או Node)
הדפדפן מורידאת האפליקציה המלאה: React, ⁦Yoga WASM⁩, הנתיבים שלהאת נגן הפריימים, כ-21 kB ב-gzip
גישה לנתוניםfetch() למטפלי נתיביםישירות: מסדי נתונים, קבצים, node:*, סודות
רכיבים אסינכרונייםשימוש ב-use() או באפקטיםתמיכה ברכיבי async function
השהיית הקשותבאותה משימה, באופן סינכרוניסבב תקשורת אחד לשרת ובחזרה
פועל באירוח סטטיכןלא, נדרש text start או bun dist/server.js

שתי סביבות הריצה מייצרות את אותם פריימים. נגן הפריימים מקודד תאים עם אותו CssConsoleDriver ובאותו קצב של כ-15 פריימים לשנייה, כך שפריים מרונדר בצד השרת תואם לקריאת console.log של הלקוח בית לבית.

הפעלה

יש לייצא runtime מעמוד:

app/status/page.tsxtsx
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 או בייצוא חוזר; בערך מחושב אי אפשר). רכיבי layout,‏ loading.tsx,‏ error.tsx ו-not-found.tsx מרונדרים במקום שבו העמוד שלהם מרונדר: layout שמשותף לעמודי לקוח ושרת רץ בשני המקומות ונשלח לדפדפן, ואילו רכיבי ה-layout והגבולות של מקטע שמכיל רק עמודי שרת נשארים בשרת. הוקים, מיקוד, useInput,‏ <Link> ו-useRouter() מתנהגים כמו בצד הלקוח.

מתי להשתמש בכל סביבה

יש להשתמש בסביבת הריצה של הלקוח (ברירת המחדל) עבור:

  • ממשקים עם הקלדה מרובה שבהם כל הקשה חייבת להיצבע באותה משימה, כמו עורכים ומסננים.
  • אפליקציות שנפרסות באירוח סטטי או ב-CDN.
  • כלים שיכולים לפעול ללא חיבור לרשת.

יש להשתמש בסביבת הריצה של השרת עבור:

  • לוחות בקרה ותצוגות ניהול שקוראים מסדי נתונים, קבצים, תורים או ממשקי API פרטיים. פרטי ההזדהות נשארים בשרת.
  • נתיבים עם תלויות כבדות (ערכות SDK, מנתחים, טבלאות תחביר) שאין צורך לכלול בחבילת הקוד לדפדפן.
  • עמודים שצריכים להיטען מהר ברשתות איטיות. הדפדפן מוריד את הנגן בגודל 21 kB (gzip) במקום את האפליקציה המלאה.

אפשר לשלב סביבות ריצה באפליקציה אחת. ניווט ביניהן טוען את כתובת היעד כמסמך חדש. חבילת הקוד לדפדפן מחליפה כל עמוד שרת בממלא מקום, כך שמודולי שרת וכל מה שהם מייבאים לעולם לא נשלחים ללקוח.

יש לתת למודולים שמיועדים לשרת בלבד שמות בתבנית *.server.ts. הבנייה נכשלת אם קוד שנשלח לדפדפן (עמוד לקוח, או layout שעמוד לקוח משתמש בו) מייבא מודול *.server.* או מודול מובנה מסוג node:*, ומציינת מי ייבא אותו.

⁦Middleware⁩ מגן על נתיבי שרת באותו אופן שבו הוא מגן על מסמכים: הוא רץ עבור נתיב היעד לפני פתיחת סשן, לפני כל ניווט בתוך הסשן (router.push, אחורה וקדימה), ושוב בכל שינוי בקובצי ה-cookie או ב-Authorization של הסשן. כשהוא אמור להפנות, לדחות או לשכתב, הדפדפן טוען במקום זאת את הכתובת כמסמך, וה-middleware משיב לבקשה הזאת.

רכיבי שרת אסינכרוניים

בשרת, כל רכיב async function משהה את הרינדור עד לגבול <Suspense> או loading.tsx הקרוב ביותר, עד שה-promise שלו נפתר:

app/deployments/live/page.tsxtsx
export const runtime = "server";async function Deployments({ team }: { team: string }) {  const rows = await db.deployments.list({ team });  return <Table data={rows} columns={COLUMNS} />;}

כל promise נשמר במטמון לכל סשן, עם מפתח לפי הרכיב וה-props, כך שניסיון רינדור חוזר משתמש שוב בבקשה שכבר מתבצעת. המטמון מתנקה כשהנתיב משתנה ובקריאה ל-router.refresh(). כדי להריץ מחדש גישה לנתונים לאחר קלט, יש לקרוא ל-router.refresh():

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

ה-props שמועברים לרכיבים אסינכרוניים צריכים להתאים לסריאליזציה: מחרוזות, מספרים, אובייקטים רגילים, מערכים, Date,‏ URL,‏ Map,‏ Set ואלמנטים. props של פונקציות אינם חלק מהמפתח (יש להעביר נתונים, לא פונקציות callback). כל סשן שומר במטמון לכל היותר 256 הבטחות promise. אפשר גם לעטוף רכיב ידנית באמצעות serverComponent(fn) מתוך @textjs/app/server-components.

הוקים (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, היסטוריית נתב בזיכרון ומאגר תאים בשרת. מזהי הסשנים הם ערכים אקראיים של 192 סיביות שהדפדפן שומר בזיכרון בלבד.
  • הפרשי תאים. השרת שולח רק שורות שערך הגיבוב שלהן השתנה מאז הפריים האחרון שהחיבור קיבל. כל שורה נשלחת כרצפי טקסט מול טבלת סגנונות שמאפשרת הוספה לסוף בלבד, וגרפמות רחבות, אשכולות אמוג'י וצבעים שקופים למחצה נשמרים במדויק.
  • לחץ חוזר. בין פריימים יש לפחות 66 ms, וכל פריים מושווה למה שהשקע קיבל לאחרונה. כשחיבור איטי מפסיק לפנות נתונים, מצבי ביניים מדולגים והפריים הבא קופץ ישירות למצב העדכני ביותר.
  • חיבור מחדש. אם הזרם מתנתק, הנגן מתחבר מחדש עם מזהה הסשן בכותרת x-textjs-live (לעולם לא בכתובת ה-URL) ומחדש את אותו סשן, עם המצב שלו ללא שינוי, מפריים מפתח חדש. עדכונים לשורת הכתובת שהוחמצו מופעלים מחדש. סשן ללא חיבור מפסיק לרנדר ומוסר לאחר 30 שניות; אם השרת שכח אותו, הנגן פותח סשן חדש.
  • היסטוריה. אחורה וקדימה מזיזים את שתי ההיסטוריות באותו מספר רשומות, וגם router.back() בשרת מזיז את היסטוריית הדפדפן.
  • אבטחה. שתי נקודות הקצה דוחות בקשות מאתרים אחרים (Sec-Fetch-Site ו-Origin;‏ X-Forwarded-Host נחשב רק עם server.trustProxy). בקשות POST של קלט דורשות גוף JSON ואת הכותרת x-textjs-live. כל אירוע נבדק מול סכמה קבועה, ויעדי הניווט חייבים להיות נתיבים באותו מקור. אפשר לרנדר רק עמודים שמופיעים במניפסט app/. סשנים קשורים לקובצי ה-cookie שאיתם נפתחו.
  • מגבלות. לכל היותר 64 אירועים לכל POST וכ-240 לשנייה לכל סשן (429 מעבר לכך), זמן קצוב של 5 שניות לגוף הבקשה, 32 סשנים לכל כתובת לקוח ו-maxSessions לכל תהליך (הסשן שמנותק הכי הרבה זמן מפונה ראשון).
text.config.tsts
export default defineConfig({  live: { maxSessions: 512, reconnectGraceMs: 60_000 },});

רינדור חלקי מראש

text build מרנדר כל נתיב סטטי פעם אחת, בגודל viewport שהוגדר, ומטמיע את הפריים הראשון ב-HTML של הנתיב בתור <script> קלאסי קטן. המסוף נצבע לפני שמודול כלשהו נטען:

  1. המעטפת הסטטית. כל מה שמרונדר באופן סינכרוני. כל גבול <Suspense> שעדיין ממתין מציג את תוכן הגיבוי שלו, כולל רכיבי שרת אסינכרוניים (שאינם רצים בזמן רינדור המעטפת), use() ומודולים בטעינה עצלה. fetch() לעולם לא נפתר בזמן רינדור המעטפת, כך שגם נתונים שנטענים באפקטים נשארים במצב טעינה.
  2. החלקים הדינמיים. כש-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)

כדי להשבית זאת, יש להגדיר ppr: false בקובץ text.config.ts. רינדור מראש מריץ את הרכיבים בזמן הבנייה, ולכן יש להשאיר את הקוד ברמה העליונה של המודול ללא משתנים גלובליים שקיימים רק בדפדפן. נתיב שזורק חריגה, קורא ל-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).

עריכת הדף ב-GitHub