İçeriğe atla

Lambda Web Functions Üzerinde React Server Components

CloudFront arkasında Lambda Web Functions üzerinde React Router RSC uygulaması: giriş dosyası, NODE_ENV, streaming, istek kapsamlı state, kısmi render ve origin hattı.

Ayhan Sipahi Ayhan Sipahi

Bir React Server Components uygulamasını AWS Lambda’da barındırmak bugüne kadar bir adaptör gerektiriyordu. Bu kod katmanı invocation event’ini Request’e çeviriyor ve stream edilen Response’u Lambda’ya özgü bir API üzerinden geri gönderiyor. Framework sunucu build’inin şeklini her değiştirdiğinde de bozuluyor. Lambda Web Functions üzerinde RSC sunucusu bir port dinleyen sıradan bir Node süreci; dolayısıyla adaptör sorunu ortadan kalkıyor. Dağıtılan uygulamanın doğru ve hızlı çalışıp çalışmayacağını bundan sonra üç şey belirliyor: sunucu build’i yüklenmeden önce NODE_ENV’in ayarlanması, framework’ün revalidation kuralları ve CloudFront ile origin arasındaki hat. Aşağıdaki yol bir React Router 8.5 RSC Framework Mode uygulamasını baştan sona geçiriyor: giriş dosyası, deploy komutları, streaming kontrolü, istek kapsamlı state, kısmi render, prerender ve CloudFront ayarları. Her adım denemenin ürettiği sayılarla birlikte geliyor.

Kullanılan yığın: react-router ve @react-router/dev 8.5.1, React 19.2, Vite 8.3.5, @vitejs/plugin-rsc 0.5.35, @remix-run/node-fetch-server 0.13.0, Express 5.3.0, compression 1.8.2, arm64 üzerinde nodejs24.x runtime’ı ve AWS CLI 2.38.4. RSC davranışı yalnızca bir barındırma kararını değiştirdiği yerde anlatılıyor. Bütün ölçümler Avrupa’daki tek bir istemci makinesinden, tek bir AWS hesabına karşı alındı; bunları benchmark olarak değil, tek bir ortamın gözlemi olarak okuyun. Kod, lockfile’lar, CloudFront şablonları, getManifestRoute yama script’i ve ölçüm script’leri ayhansipahi/rsc-on-lambda-web-functions (yeni sekmede açılır) reposunda. Hono lambdalith yazısı Web Functions kaynak modelini, auth tiplerini ve endpoint ayarlarını anlatıyor. Bir sonraki bölüm yalnızca RSC sunucusunun dayandığı maddeleri tekrar ediyor.

Runtime’ın RSC Sunucusuyla Yaptığı Sözleşme#

Süreç, AWS_LAMBDA_HTTP_ENDPOINT içindeki host:port adresini 10 saniye içinde dinlemeye başlamak zorunda. Runtime PORT değişkeni vermiyor (SAM WebFunction referansı (yeni sekmede açılır)); giriş noktası paket kökündeki index.js. Bir ortamın 2 vCPU’su ve 2 GB belleği var; varsayılan olarak aynı anda 64 isteğe kadar kabul ediyor (maxConcurrencyPerEnvironment, 1 ile 128 arasında ayarlanabilir). Bu sayı bir tavan: Lambda ortam başına gerçek sınırı bunun altında dinamik olarak belirliyor. İstek zaman aşımı varsayılan 30 saniye (3 ile 900 arası), dağıtım paketi açılmış hâlde 512 MB’a kadar çıkabiliyor. Web Functions HTTP sayfasına (yeni sekmede açılır) göre endpoint HTTP/1.1 konuşuyor; sıkıştırma da yapmıyor. Denemede TLS ALPN h2 ve http/1.1 teklif etti, endpoint http/1.1’i seçti; accept-encoding: gzip, br ile gönderilen istek content-encoding olmadan döndü. Lambda SSS sayfasına (yeni sekmede açılır) göre faturalamanın üç boyutu var: istek sayısı, aktif CPU süresi ve bellek. I/O beklerken geçen CPU süresi faturalanmıyor.

Bir RSC sunucusu bu şekle oturuyor. İstek, Suspense sınırları çözülene kadar bağlantısını açık tutuyor; bu da lansman yazısının hedef iş yükü olarak andığı sunucu tarafı render. Tek bir event loop aynı anda 64 isteğe hizmet verdiği için modül kapsamında tutulan her şey kullanıcılar arasında paylaşılıyor. Bu, modül kapsamını SDK client’ları, bağlantı havuzları ve değişmeyen yapılandırma için doğru yer, tek bir isteğe ait veri için ise yanlış yer yapıyor; aşağıdaki eşzamanlılık bölümü ikinci durumu test ediyor. Endpoint’in yalnızca HTTP/1.1 konuşması da ileride önem kazanacak. CloudFront ile origin arasındaki hatta multiplexing olmadığı için bağlantının yeniden kullanılıp kullanılmadığı doğrudan gecikmeye yansıyor.

Giriş Dosyası#

Dağıtılan sunucunun tamamı tek bir dosya.

// Lambda Web Functions girişi: runtime index.js'i yükler ve AWS_LAMBDA_HTTP_ENDPOINT'i yoklar.
import zlib from "node:zlib";
import compression from "compression";
import express from "express";
import { createRequestListener } from "@remix-run/node-fetch-server";

// React yüklenmeden önce ayarlanmalı: yoksa sunucu React'in dev build'ini kullanır ve Flight
// payload'ı production istemcinin hiç hydrate etmediği debug satırları taşır. Statik import'lar
// hoist edildiği için build aşağıda dinamik olarak import ediliyor.
process.env.NODE_ENV ??= "production";
// RR 8.5 `default: { fetch }` export ediyor; RSC dokümanlarındaki çıplak handler şekli 8.5.1'de yok.
const { default: build } = await import("./build/server/index.js");

const [host, port] = (process.env.AWS_LAMBDA_HTTP_ENDPOINT || "0.0.0.0:3000").split(":");

const app = express();
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
app.use(
  express.static("build/client", {
    // Prerender edilmiş sayfalar: CDN 5 dk tutar, tarayıcı her seferinde yeniden doğrular.
    setHeaders: (res, path) => {
      if (/\.(html|rsc)$/.test(path)) res.setHeader("Cache-Control", "public, max-age=0, s-maxage=300");
      // mime-db'de .rsc kaydı yok; send application/octet-stream'e düşüyor ve CloudFront
      // bunu sıkıştırmıyor. text/x-component sıkıştırılabilir tipler listesinde.
      if (path.endsWith(".rsc")) res.setHeader("Content-Type", "text/x-component");
    },
  }),
);
// Ne Web Functions endpoint'i ne CloudFront stream edilen (Content-Length'siz) yanıtları
// sıkıştırır. Her yazmada flush etmek Suspense chunk'larını buffer'lamadan akıtmaya devam eder.
app.use(
  compression({
    flush: zlib.constants.Z_SYNC_FLUSH,
    brotli: { flush: zlib.constants.BROTLI_OPERATION_FLUSH },
  }),
);
app.use(createRequestListener(build.fetch));
app.listen(port, host, () => console.log(`listening on ${host}:${port}`));

Bu dosyadaki dört karar, göründükleri sırayla önemli.

Build yüklenmeden önce NODE_ENV. ESM’de statik import’lar modüldeki diğer bütün ifadelerin üstüne taşınır (hoisting). Sunucu build’inin statik import’u bu yüzden React’i atama satırı çalışmadan yüklerdi; sırayı garanti eden, dinamik await import(). production ayarlanmazsa sunucu React’in geliştirme build’ini seçiyor ve bunu hiçbir yere yazmıyor. Denemede /dash/a dokümanı 5.471 bayttan 17.786 bayta çıktı ve Flight payload’ı "/server/index.js",6481,17 gibi stack konumları taşıyan debug satırlarıyla doldu. Production istemci hydrateRoot çağırmadı ve her link tam sayfa yüklemesine düştü. Konsol temiz kaldı. react-router-serve bunların hepsinden CLI’ının en üstündeki process.env.NODE_ENV = process.env.NODE_ENV ?? "production"; satırıyla kurtuluyor. Sorun bu yüzden ancak onun yerine özel bir shim konduğunda ortaya çıkıyor. Bu denemede runtime değişkeni sağlamadı: shim ayarlayana kadar sunucu React’in geliştirme build’ini yükledi. ??= yalnızca eksik bir değeri dolduruyor. Ortam değişkenlerinde NODE_ENV=development tanımlı bir revizyon geliştirme build’inde kalır; orada değişkeni boş bırakın ya da doğrudan "production" atayın.

Handler olarak build.fetch. React Router’ın RSC rehberi sunucu build’inin default export’unu doğrudan createRequestListener’a veriyor. 8.5.1 build’i default: { fetch } export ediyor; react-router-serve da bir RSC build’ini default.fetch var mı diye bakarak tanıyor. Nesnenin kendisini verirseniz ilk istekte TypeError: handler is not a function alırsınız.

Statik dosyalar sıkıştırmanın önünde. Hash’li /assets dizini immutable ve bir yıllık max-age ile gidiyor. Dosya adında hash olduğu için içerik değişince URL de değişiyor ve tarayıcı bir yıl boyunca yeniden doğrulamaya bile gitmiyor. Prerender edilmiş .html ve .rsc dosyaları public, max-age=0, s-maxage=300 alıyor: CloudFront beş dakika tutuyor, tarayıcı her ziyarette yeniden doğruluyor. İki statik handler da bilerek compression middleware’inin önünde. express.static Content-Length gönderdiği için bu dosyaları CloudFront kendi kurallarına göre edge’de sıkıştırıyor. Uygulamanın sıkıştırması gereken tek şey stream ettiği yanıtlar. Bunun için bir dosya tipine ayrıca header gerekti. express.static’in kullandığı MIME veritabanında .rsc kaydı yok; about.rsc bu yüzden application/octet-stream ile gitti ve bu tip CloudFront’un sıkıştırılabilir tipler listesinde yok. setHeaders içinde Content-Type: text/x-component ayarlanınca CloudFront dosyayı Brotli ile sundu: ağ üzerinde 2.039 yerine 725 bayt.

Flush eden sıkıştırma. compression, RSC handler’ını gzip için Z_SYNC_FLUSH, Brotli için BROTLI_OPERATION_FLUSH ile sarıyor. Middleware zlib ve Brotli seçeneklerini olduğu gibi geçiriyor. README’si flush olmadan server-sent event’lerle çalışmadığını söylüyor (“does not work out of the box with server-sent events”); chunk’lı bir RSC yanıtı aynı sorunu yaşıyor. Uygulamanın neden sıkıştırmak zorunda olduğu CloudFront bölümünün konusu. Ödünleşim açık. Flush edilen her chunk, buffer’lanmış bir stream’e göre biraz daha kötü sıkışıyor (Node’un zlib dokümanı (yeni sekmede açılır) flush’ın “may come at the cost of degraded compression quality” olduğunu, yani sıkıştırma kalitesinden ödün verdirebileceğini yazıyor) ve fonksiyon bunun için CPU harcıyor; o CPU da aktif süre olarak faturalanıyor. react-router-serve tam tersi kararı veriyor ve RSC build’lerinde compression middleware’ini hiç kullanmıyor. Deneme baytları ölçtü, CPU süresini ölçmedi. 5.471 bayttan 1.726 bayta inen bir doküman için daha küçük transfer büyük olasılıkla kazançlı, ama bu bir ölçüm değil, bir değerlendirme. Fonksiyonun metriklerinde aktif CPU’ya bakın; fonksiyon CPU’ya bağlıysa ve payload’lar küçükse middleware’i çıkarın.

Build, Prune ve Deploy#

Deploy komutu bundle yapmıyor; bu yüzden önce build çalışıyor, paketlemeden önce de geliştirme bağımlılıkları çıkıyor.

npm run build
npm prune --omit=dev
aws lambda-web deploy --name rr-rsc-webfn --code . --create --auth-type ApplicationManaged --region us-east-1

react-router build, build/server ve build/client dizinlerini yazıyor. aws lambda-web deploy --code . dizini olduğu gibi zip’liyor. CLI referansı komutun uygulamayı bundle etmediğini açıkça yazıyor (“The command does not bundle the application”) ve node_modules zip’in içinde gidiyor. npm prune --omit=dev önce Vite, @vitejs/plugin-rsc ve TypeScript’i çıkarıyor. Paket yaklaşık 45 MB oldu, 512 MB sınırının epey altında. .env ve .git/ gibi gizli yollar atlanıyor; .next/, .output/ ve birkaç başka yol için belgelenmiş bir izin listesi var. Sonraki deploy’lar aynı komutu --create olmadan kullanıyor. Bir sonraki build’den önce npm install yeniden çalışmak zorunda. Bu yeniden kurulum node_modules altında yamalanmış her şeyi de geri alıyor; kısmi render bölümü bu noktaya geri dönecek.

Sürüm sabitlemesi ve fonksiyon adı da bu adımdan çıkıyor. @vitejs/plugin-rsc 0.5.35’e sabitlendi, çünkü 0.5.36 ile 0.5.38 arası sürümler React Router 8.5.1 build’ini Error: Vite RSC assets manifest not found ile düşürdü. Sebep React Router’ın kendi Vite plugin’inde (remix-run/react-router#15671 (yeni sekmede açılır)). Düzeltme @react-router/dev 8.5.2 için merge edildi; o sürüm çıkınca sabitleme kaldırılabilir. SAM AWS::Serverless::WebFunction referansına (yeni sekmede açılır) göre fonksiyon adları bir hesapta bütün Region’lar genelinde tekil. Frankfurt kopyası bu yüzden kendi adını aldı: --region eu-central-1 ile rr-rsc-webfn-fra.

Streaming’in Bozulmadığını Doğrulamak#

/stream route’unda 1 ve 3 saniye sonra çözülen iki Suspense sınırı var.

import { Suspense } from "react";

// Async sunucu bileşeni: `ms` sonra çözülür, HTML'i bu yüzden daha sonraki bir chunk'ta gelir.
async function Slow({ ms }: { ms: number }) {
  await new Promise((r) => setTimeout(r, ms));
  return <p>slow-{ms}ms rendered at {new Date().toISOString()}</p>;
}

export function ServerComponent() {
  return (
    <main className="space-y-2">
      <h1 className="text-2xl font-semibold">shell rendered at {new Date().toISOString()}</h1>
      <Suspense fallback={<p>loading-1000</p>}>
        <Slow ms={1000} />
      </Suspense>
      <Suspense fallback={<p>loading-3000</p>}>
        <Slow ms={3000} />
      </Suspense>
    </main>
  );
}

Kontrol, her yanıt chunk’ının ne zaman geldiğini yazdırıyor; streaming ile buffering’i ayıran tam olarak bu.

// Her yanıt chunk'ının ne zaman geldiğini yazdırır; buffering ile streaming böylece görünür olur.
// kullanım: node probe.mjs <url> [Accept header]
const [url, accept = "text/html"] = process.argv.slice(2);
const t0 = performance.now();
const res = await fetch(url, { headers: { accept } });
const ms = () => String(Math.round(performance.now() - t0)).padStart(5);
console.log(`${ms()}ms status=${res.status} type=${res.headers.get("content-type")} te=${res.headers.get("transfer-encoding")}`);
const dec = new TextDecoder();
let n = 0;
for await (const chunk of res.body) {
  const text = dec.decode(chunk, { stream: true });
  const hits = text.match(/shell rendered|loading-\d+|slow-\d+ms/g) ?? [];
  console.log(`${ms()}ms chunk#${++n} ${chunk.length}B ${[...new Set(hits)].join(",")}`);
}
console.log(`${ms()}ms done, ${n} chunks`);

us-east-1 endpoint’ine doğrudan gidildiğinde ilk baytlar yaklaşık 124 ms’de, iki Suspense chunk’ı ise yaklaşık 1,12 s ve 3,12 s’de geldi. Aynı route’un HTML dokümanı da .rsc payload’ı da chunk’lı geldi; endpoint’te buffering yoktu. Asıl ölçüt chunk’ın varış zamanı. Buffer’lanmış bir yanıt da yaklaşık 3,1 s’de biter; toplam süreye bakan bir kontrol iki durumda da geçer. Compression middleware’i eklendikten sonra chunk’lar yine +1 s ve +3 s’de düştü; flush seçeneklerinin işini yaptığının testi bu. Her middleware değişikliğinden sonra yeniden koşturmaya değer.

64 Eşzamanlı İstek Altında İstek Kapsamlı State#

Kişiselleştirilmiş bir route çerezi loader’da okuyor, yanıtı private olarak işaretliyor ve çerezi bir redirect üzerinden yazıyor.

import { redirect } from "react-router";
import { NameField, SubmitButton } from "../field";

const readName = (request: Request) =>
  decodeURIComponent(request.headers.get("cookie")?.match(/(?:^|;\s*)name=([^;]+)/)?.[1] ?? "");

// Kişiselleştirilmiş sayfa: paylaşımlı bir CDN tarafından asla cache'lenmemeli.
export function headers() {
  return { "Cache-Control": "private, no-store" };
}

export function loader({ request }: { request: Request }) {
  return { name: readName(request) };
}

export async function action({ request }: { request: Request }) {
  const name = String((await request.formData()).get("name") ?? "").slice(0, 32);
  // POST/redirect/GET: RSC modunda (RR 8.5.1) action yanıtı render edilirken loaderData null.
  return redirect("/me", {
    headers: { "Set-Cookie": `name=${encodeURIComponent(name)}; Path=/; HttpOnly; Secure; SameSite=Lax` },
  });
}

export function ServerComponent({ loaderData }: { loaderData: { name: string } }) {
  return (
    <main className="space-y-4">
      <h1 className="text-2xl font-semibold">hello {loaderData.name || "stranger"}</h1>
      <form method="post" className="flex items-end gap-2">
        <NameField defaultValue={loaderData.name} />
        <SubmitButton>Save</SubmitButton>
      </form>
    </main>
  );
}

private, no-store önüne bir CDN geçince önem kazanıyor, çünkü CloudFront bölümündeki cache policy çerezleri cache anahtarının dışında tutuyor. Redirect ayrıca 8.5.1’deki bir render yolunu da atlatıyor. Action’dan data(null, { headers }) döndürmek action yanıtını loaderData === null ile render etti ve bileşen TypeError: Cannot read properties of null (reading 'name') ile HTTP 500 verdi.

Bozuk varyant çerez değerini bir modül değişkeninde tutuyor ve veritabanı çağrısının yerine 50 ms bekliyor. O 50 ms içinde aynı event loop’a düşen başka bir istek current değişkeninin üzerine yazıyor. İlk istek uyanıp değeri okuduğunda artık başkasının adını görüyor.

// Bilerek bozuk. Modül düzeyindeki state bir Web Functions ortamındaki her eşzamanlı
// istek tarafından paylaşılır; adlar bu yüzden kullanıcılar arasında sızar.
let current = "";

export async function loader({ request }: { request: Request }) {
  current = decodeURIComponent(request.headers.get("cookie")?.match(/(?:^|;\s*)name=([^;]+)/)?.[1] ?? "");
  await new Promise((r) => setTimeout(r, 50)); // DB çağrısının yerine
  return { name: current };
}

export function ServerComponent({ loaderData }: { loaderData: { name: string } }) {
  return <p>hello {loaderData.name}</p>;
}

Test, us-east-1 endpoint’ine her biri kendi name=u{i} çereziyle 200 paralel istek gönderdi. Bu route çoğu koşuda 127 ile 128 yanıtı başka bir kullanıcının adıyla döndürdü; bir koşuda 9, bir koşuda 0. İstek kapsamlı sürüm her koşuda 200’de 0 döndürdü. Yerelde, tek süreçte, bozuk route 64 isteğin 63’ünü yanlış yanıtladı.

Koşular arasındaki fark büyük olasılıkla 200 isteğin ortamlara nasıl dağıldığından geliyor. Ölçekleme sayfası, yeni bir Web Function için temkinli başlayan dinamik bir ortam başına eşzamanlılık sınırı tanımlıyor (“starts conservatively for a new Web Function”). Aynı test bu yüzden bir koşudan diğerine daha çok ya da daha az ortama yayılabiliyor. Bu bağlantı dokümantasyondan çıkarılmış bir sonuç. Aynı sayfa, yeni bir fonksiyondaki ilk yük testinin sonrakilerden daha fazla ortam kullanabileceğini belirtiyor ve sonraki koşuların sonuçlarının esas alınmasını öneriyor. Deploy’un hemen ardından koşulan bir sızıntı testi bu yüzden sorunu en çok gözden kaçırması muhtemel olan test. Pratik sonucu ise her durumda geçerli: sıfır yanlış yanıt veren bir koşu, route’un güvenli olduğunu kanıtlamıyor. Kuralı runtime sayfası koyuyor: istek başına state’i global değişkenlerde tutmayın (“avoid storing per-request state in global variables”). Önceki yazının eşzamanlılık denetimi, süreç başına tek istek varsayımıyla yazılmış bir kod tabanında nelere bakılacağını listeliyor.

Yalnızca Değişen Route’u Render Etmek#

İlk dokümandan sonra bir link tıklaması, sonraki route’un Flight payload’ını /path.rsc adresinden çekiyor ve istemci ağacı yerinde yamalıyor. React Router varsayılan olarak bu isteklerin her birinde root, layout ve sayfayı sunucuda yeniden render ediyor. Buna gerek duymayan route’lar için çıkış yolu shouldRevalidate.

// app/routes/dash.tsx
// Çocuktan çocuğa geçişte layout'u yeniden render etmeyi atla.
// RR 8.5.1 bu export'u route discovery yamalarından (getManifestRoute) düşürüyor; bu yüzden yalnızca
// /dash ilk yüklenen sayfayken uygulanıyordu. Bu demo için node_modules/react-router yamalandı.
export function shouldRevalidate() {
  return false;
}
// app/root.tsx
import type { Route } from "./+types/root";

// Root'un loader'ı yok ve NavLink aktif durumu istemcide hesaplıyor; bir link tıklaması
// sunucunun onu yeniden render etmesini gerektirmiyor. Form gönderimleri yine revalidate eder.
export function shouldRevalidate({ formMethod, defaultShouldRevalidate }: Route.ShouldRevalidateFunctionArgs) {
  return formMethod ? defaultShouldRevalidate : false;
}

İkisi de yerindeyken istemci /dash/b.rsc?_routes=../routes/dash.child istiyor. Layout’un render zaman damgası çocuk geçişlerinde sabit kalıyor, layout’taki istemci state’i (bir sayaç) korunuyor ve root hiç yeniden render edilmiyor. Payload /dash’e girişte 3.292 bayt, sonrasındaki her yalnızca-çocuk geçişinde 2.283 bayt oldu. Barındırma tarafındaki sonuç o URL’nin içinde. _routes isteğin kimliğinin parçası; CloudFront cache policy’si bütün query string’leri hem iletmek hem de anahtara katmak zorunda. Parametre origin’e ulaşmazsa sunucu yine bütün ağacı render eder. Anahtara girmezse, cache’lenebilir işaretlenmiş bir yanıtta farklı _routes değerleri aynı girdiyi paylaşır.

dash.tsx içindeki yorum bir React Router 8.5.1 davranışına işaret ediyor. Route discovery yamalarının arkasındaki sunucu fonksiyonu getManifestRoute (lazy discovery ve routeDiscovery: "initial" tarafından kullanılıyor), döndürdüğü route nesnesinden shouldRevalidate’i düşürüyor. Bu yüzden bir layout’un shouldRevalidate’i yalnızca o layout ilk doküman yüklemesinin parçasıysa uygulanıyor. /dash’e bir link üzerinden girmek, her çocuk geçişinde layout’u yeniden render etti. Canlıda doğrulanan tek satırlık düzeltme, getManifestRoute’un döndürdüğü nesneye shouldRevalidate: route.shouldRevalidate eklemek; hem react-router/dist/development/index-react-server.js hem react-router/dist/production/index-react-server.js içinde. Denemede node_modules doğrudan düzenlendi; bir sonraki npm install bunu geri alıyor. Paket yöneticinizin patch mekanizmasıyla kalıcı hâle getirin ve upstream bir sürüm alanı taşır taşımaz kaldırın. getManifestRoute içindeki değişiklik:

   links: route.links,
-  meta: route.meta
+  meta: route.meta,
+  shouldRevalidate: route.shouldRevalidate
 };

Ayarın iki tarafında da bir maliyet var. Koşulsuz false döndürmek layout’u tam yeniden yüklemeye kadar render edildiği hâlde bırakıyor; istemci tarafındaki form gönderimlerinden sonra da. Bir mutasyondan sonra yenilenmesi gereken layout, root’taki kalıbı kullanmalı: formMethod ? defaultShouldRevalidate : false. Denemedeki dash layout’unda mutasyon olmadığı için doğrudan false döndü. Canlı veri gösteren bir layout varsayılana muhtaç. 8.5.1’de bu ayrıca bir framework bağımlılığına yama taşımak demek; her yükseltmede yeniden kontrol edilmesi gereken bir yük. Pahalı veri tutan bir layout için yama karşılığını veriyor: her çocuk geçişi bir layout render’ı ve bu uygulamada yaklaşık 1 KB kazandırıyor. İnce bir layout için varsayılan revalidation yamadan daha ucuz.

Edge’de Prerender Edilmiş Sayfalar#

Cache TTL’inden daha yavaş değişen route’lar fonksiyonu tamamen terk edebilir.

import type { Config } from "@react-router/dev/config";

export default {
  prerender: ["/about"],
} satisfies Config;

Prerender, RSC Framework Mode’da 8.5.1 ile çalışıyor: build hem build/client/about/index.html hem build/client/about.rsc üretti. Shim ikisini de express.static üzerinden public, max-age=0, s-maxage=300 ile servis ediyor. CloudFront üzerinden bir edge hit yaklaşık 15 ms’de yanıt verdi; us-east-1 origin’inden ise yaklaşık 119 ms.

Route başına takip etmeye değer diğer sayı istemci JavaScript’i. Framework kabuğu gzip’li yaklaşık 114 KB. İlk sürümde tek bir "use client" barrel dosyası bütün Base UI bileşenlerini export ediyordu ve root layout navigasyonu bu dosyadan import ediyordu. Her sayfa bu yüzden modülün tamamını yükledi: her sayfada yaklaşık 42 KB fazlası (yaklaşık 114 KB’tan 156 KB’a). Bileşen başına bir istemci modülü, UI kullanmayan sayfaları yaklaşık 114 KB’a geri getirdi. Navigasyon bu modüllerden biri.

"use client";

import { NavLink } from "react-router";

const pages = [
  ["/", "Home"],
  ["/stream", "Stream"],
  ["/me", "Cookie"],
  ["/dash/a", "Dash"],
  ["/about", "About"],
] as const;

export function Nav() {
  return (
    <nav className="flex gap-1 border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
      {pages.map(([to, label]) => (
        <NavLink key={to} to={to} end={to === "/"}>
          {label}
        </NavLink>
      ))}
    </nav>
  );
}

Endpoint’in Önünde CloudFront#

Dağıtım, Web Function endpoint’ini düz bir HTTPS custom origin olarak kullandı: ApplicationManaged bir endpoint, origin access control yok. En basit şekil bu ve deneme için yeterli oldu. Endpoint yine de kendi alan adından erişilebilir kalıyor; CloudFront’u ve ona bağlı her WAF kuralını atlıyor. Production bir ön kapı için IamAuth artı OAC bu yolu kapatıyor; kurulumu önceki yazı anlatıyor ve burada denenmedi.

h2 ya da h3

HTTP/1.1, keepalive içinde yeniden kullanılır

parçalı, br ile sıkıştırılmış

parçalar olduğu gibi geçer

Tarayıcı

CloudFront edge (anahtar: yol + query)

Web Function: shim + RSC handler

Cache Policy ve Host Header’ı#

İlk dağıtım, yönetilen UseOriginCacheControlHeaders-QueryStrings cache policy’sini (ID 4cc15a8a-d715-48a4-82b8-cc0b614638fe) yönetilen AllViewerExceptHostHeader origin request policy’siyle (ID b689b0a8-53d0-40ab-baf2-68738e2966ac) kullandı. Yönetilen origin request policy sayfası (yeni sekmede açılır) bu policy’yi Lambda Web Function origin’leri için tasarlanmış olarak tanımlıyor. /assets/* dışındaki her yol 502 ERROR The request could not be satisfied ve x-cache: Error from cloudfront döndürdü. Asset davranışı CachingOptimized kullanıyordu ve çalışmaya devam etti.

Sebep cache anahtarı. O yönetilen policy anahtara Host, Origin, X-HTTP-Method-Override, X-HTTP-Method, X-Method-Override ve bütün çerezleri katıyor. CloudFront’un origin request sayfası, cache anahtarına konan her query string, header ve çerezin origin isteğine de otomatik olarak eklendiğini yazıyor (“All URL query strings, HTTP headers, and cookies that you include in the cache key (using a cache policy) are automatically included in origin requests.”). Yani origin request policy’si Host’u çıkarmış olsa da Host: <dağıtım>.cloudfront.net origin’e ulaştı. Origin ayarları sayfası bunun sonucunu yazıyor: viewer host header’ını origin’e ileten bir policy kullanılıyorsa origin, o host header’ıyla eşleşen bir sertifika döndürmek zorunda. Web Function’ın sertifikası eşleşmiyor; origin’e TLS başarısız oldu ve CloudFront 502 verdi. Önceki yazının tuzak listesi, endpoint’in yabancı bir Host aldığında kendi döndürdüğü 404’ü anlatıyor. Bu denemede istek o noktaya hiç ulaşmadı.

Düzeltme, anahtarında hiç header ve çerez olmayan özel bir cache policy: yalnızca yol, bütün query string’ler ve policy’de gzip ya da Brotli açık olduğunda CloudFront’un eklediği normalize edilmiş Accept-Encoding değeri.

{
  "Name": "rr-rsc-webfn-origin-cc-qs",
  "Comment": "Honor origin Cache-Control; key on query strings only (no Host: it breaks SNI to the Web Function origin)",
  "MinTTL": 0,
  "DefaultTTL": 0,
  "MaxTTL": 31536000,
  "ParametersInCacheKeyAndForwardedToOrigin": {
    "EnableAcceptEncodingGzip": true,
    "EnableAcceptEncodingBrotli": true,
    "HeadersConfig": { "HeaderBehavior": "none" },
    "CookiesConfig": { "CookieBehavior": "none" },
    "QueryStringsConfig": { "QueryStringBehavior": "all" }
  }
}

Query string’ler _routes için anahtarda kalıyor. Origin request policy AllViewerExceptHostHeader olarak kalıyor; çerezler ve viewer’ın User-Agent’ı anahtara girmeden fonksiyona ulaşmaya devam ediyor. Ödünleşim: çerezler anahtarın dışındayken origin’in cache’lenebilir işaretlediği her yanıt kullanıcılar arasında paylaşılıyor. Kişiselleştirilmiş route’lar bu yüzden /me’nin yaptığı gibi private, no-store göndermek zorunda. Varsayılan TTL’in 0 olması etkiyi açıkça s-maxage ya da max-age gönderen route’larla sınırlıyor. Cache-Control taşımayan bir yanıt hiç cache’lenmiyor.

Edge’in Dokunmadığı Yanıtları Uygulamada Sıkıştırmak#

CloudFront bir yanıtı yalnızca origin Content-Length gönderdiğinde ve boyut 1.000 ile 10.000.000 bayt arasındayken sıkıştırıyor. Header yoksa nesne olduğu gibi geçiyor. Stream edilen bir RSC ya da HTML yanıtında Content-Length yok, endpoint de sıkıştırmıyor. Shim’deki middleware olmadan her doküman ve .rsc payload’ı viewer’a sıkıştırılmadan ulaştı. Middleware ile /dash/a Brotli’de 5.471 bayttan 1.726 bayta, .rsc payload’ı 2.344 bayttan 755 bayta indi. Streaming chunk’ları yine +1 s ve +3 s’de geldi. Origin Content-Encoding gönderdiğinde CloudFront yeniden sıkıştırmıyor; davranıştaki Compress: true ile uygulamadaki middleware birbiriyle çatışmıyor.

HTTP/2 Yalnızca Viewer Tarafında#

Viewer ile CloudFront arası h2 üzerinden çalıştı; HttpVersion: http2and3 ile h3 açıktı ve tarayıcı geçişleri h3 üzerinden gözlendi. Custom origin sayfası CloudFront’un custom origin’e HTTP/1.1 ile gittiğini yazıyor (“CloudFront forwards requests to your custom origin using HTTP/1.1.”). Endpoint de HTTP/1.1 konuşuyor. Origin hattında multiplexing yok; bir edge’den gelen her eşzamanlı istek kendi origin bağlantısına ihtiyaç duyuyor. O bağlantının zaten açık olup olmadığı keepalive sorusu.

Origin Keepalive#

CloudFront’un varsayılan origin keepalive süresi 5 saniye. CloudFront kota sayfası (yeni sekmede açılır) origin başına aralığı 1 ile 300 saniye olarak listeliyor; 60 saniye için kota talebi gerekmiyor. Origin us-east-1’deyken ve varsayılanla, aynı istemciden CloudFront üzerinden tarayıcı geçişleri yaklaşık 330 ms sürdü; endpoint’e doğrudan 125 ile 132 ms.

Bir response headers policy sebebi görünür kılıyor.

{
  "Name": "rr-rsc-webfn-server-timing",
  "Comment": "Expose CloudFront Server-Timing (upstream connect, first byte) on every response",
  "ServerTimingHeadersConfig": { "Enabled": true, "SamplingRate": 100 }
}

cdn-upstream-connect metriği, origin DNS isteğinin tamamlanmasıyla origin’e TCP (ve varsa TLS) bağlantısının kurulması arasındaki milisaniye; sıfır değer CloudFront’un mevcut bir bağlantıyı yeniden kullandığını gösteriyor (“A value of zero (0) indicates that CloudFront reused an existing connection.”). 5 saniyelik varsayılanla, 6 saniye aralıkla gönderilen 8 isteğin 7’si yeni bir origin bağlantısı açtı; 300 ms aralıkta bile üçte biri ile yarısı açtı. İkinci gözlemin belgelenmiş bir sebebi yok. OriginKeepaliveTimeout 60’a çıkarıldığında 6 saniye aralıklı 8 isteğin 0’ı yeni bağlantı açtı. cdn-upstream-* metrikleri yalnızca cache miss’lerde görünüyor; keepalive testleri cache’lenmemiş bir yola ihtiyaç duyuyor.

Ödünleşim: daha uzun keepalive, multiplexing yapamayan bir HTTP/1.1 hattında daha az TLS el sıkışması demek. Boşta duran origin bağlantılarının Web Function ortamlarını daha uzun canlı tutup tutmadığı, dolayısıyla bellek faturasını uzatıp uzatmadığı ne belgelenmiş ne de burada ölçülmüş durumda.

Origin Region’ı#

Başka hiçbir ayar gecikmeyi bunun kadar oynatmadı. İstemci makinesinden doğrudan origin TTFB’si us-east-1 için 118 ile 126 ms, eu-central-1 için 19 ile 26 ms. Origin eu-central-1’de, keepalive 60 saniye ve kısmi render açıkken CloudFront üzerinden geçişler 36 ile 63 ms sürdü. Doküman TTFB’si 28 ile 32 ms oldu ve /stream route’u parçalarını 72, 1.084 ve 3.071 ms’de teslim etti. eu-central-1 lansman duyurusunun Region listesinde yok (us-east-1, us-east-2, us-west-2, ap-northeast-1, eu-west-1). aws lambda-web bu deneme sırasında orada çalıştı; güvenmeden önce bölgesel kullanılabilirlik sayfasından doğrulayın. Kıtalara yayılmış kullanıcılar için dokümantasyon çok Region’lı endpoint’leri anlatıyor; bu deneme onları test etmedi.

Ayar Bazında Override Durumları#

Yukarıdaki ayarlar tek bir varsayılanı oluşturuyor; tablo her birini geçersiz kılan durumu adlandırıyor.

AyarBu kurulumdaki varsayılanNe zaman değişir
Cache anahtarıYol, bütün query string’ler, normalize edilmiş Accept-Encoding; çerezler AllViewerExceptHostHeader ile origin’e ulaşırBir route edge’de çerez başına değişmek zorundaysa (nadir; onun yerine private gönderin)
Edge TTLOrigin s-maxage ya da max-age göndermedikçe 0Prerender edilmiş ya da yavaş değişen route’lar: yanıta s-maxage koyun
Layout revalidationRoot ve layout’larda shouldRevalidate false döner (root form gönderimlerinden sonra yine revalidate eder)Layout verisi çocuk geçişinde ya da mutasyonlardan sonra değişiyorsa veya 8.5.1 yaması kabul edilemezse
SıkıştırmaUygulamada, flush seçenekleriyleFonksiyon CPU’ya bağlı ve payload’lar küçükse
Origin Region’ıKullanıcıların çoğuna en yakın RegionKüresel dağılmış kullanıcılar: çok Region’lı endpoint (belgelenmiş, burada test edilmedi)
Origin keepalive60 saniyeGerçek istek aralıklarında miss’lerin çoğunda cdn-upstream-connect zaten 0 ise
Endpoint authCloudFront arkasında ApplicationManaged (yalnızca deneme)Her production ön kapısı: OAC ile IamAuth

Build Sırasıyla Hatalar ve Belirtiler#

Her madde gözlenen mesaj ya da belirti, ardından işe yarayan düzeltme.

@vitejs/plugin-rsc 0.5.36 ile 0.5.38 arasında build düşüyor

Error: Vite RSC assets manifest not found

Düzeltme: @react-router/dev 8.5.2, #15671’in düzeltmesiyle çıkana kadar @vitejs/plugin-rsc’yi 0.5.35’e sabitleyin.

Tek bir "use client" barrel dosyası

Belirti: bileşenlerin hiçbirini kullanmayan sayfalar dahil her sayfada gzip’li yaklaşık 42 KB, çünkü root layout navigasyonu bu dosyadan import ediyordu.

Düzeltme: bileşen başına bir istemci modülü.

Shim build’in default export’unu handler olarak veriyor

TypeError: handler is not a function

Düzeltme: createRequestListener’a build.fetch verin.

Build yüklenirken NODE_ENV boş (hata yok)

Belirti: /dash/a dokümanı 5.471 yerine 17.786 bayt, Flight payload’ı "/server/index.js",6481,17 gibi satırlar taşıyor, linkler tam sayfa yüklemesi yapıyor ve konsol temiz.

Düzeltme: sunucu build’inin dinamik import()’undan önce process.env.NODE_ENV ??= "production".

Stream edilen yanıtlar sıkıştırılmadan geliyor

Belirti: viewer’da HTML ve .rsc yanıtlarında content-encoding yok.

Düzeltme: uygulamada Z_SYNC_FLUSH ve BROTLI_OPERATION_FLUSH ile sıkıştırın.

Action data(null, { headers }) döndürüyor

TypeError: Cannot read properties of null (reading 'name')

Düzeltme: action yanıtı hiç render edilmesin diye Set-Cookie header’ıyla redirect() döndürün.

İstek başına değerler modül kapsamında

Belirti: 200 paralel istekte çoğu koşuda 127 ile 128 yanıt başka bir kullanıcının adını taşıyor; bir koşuda 9, bir koşuda 0.

Düzeltme: istek başına değerleri loader kapsamında tutun ve sıfır yanlış yanıtlı bir koşuyu kanıt saymayın.

shouldRevalidate’e rağmen layout yeniden render ediliyor

Belirti: layout’a bir link üzerinden girildiğinde zaman damgası her çocuk geçişinde değişiyor.

Düzeltme: iki index-react-server.js build’inde de getManifestRoute’un döndürdüğü nesneye shouldRevalidate: route.shouldRevalidate ekleyin; upstream taşıyana kadar paket yaması olarak saklayın.

CloudFront üzerinden asset dışı her yolda 502

502 ERROR The request could not be satisfied
x-cache: Error from cloudfront

Düzeltme: anahtarında Host olmayan özel bir cache policy, origin request policy olarak AllViewerExceptHostHeader.

Geçişler CloudFront üzerinden doğrudan olduğundan yavaş

Belirti: dağıtım üzerinden yaklaşık 330 ms, doğrudan 125 ile 132 ms; isteklerin çoğunda cdn-upstream-connect sıfırın üstünde.

Düzeltme: OriginKeepaliveTimeout’u yükseltin (denemede 60 saniye) ve Server-Timing header’ıyla doğrulayın.

TanStack Start’ta Farklı Olanlar#

TanStack Start, Server Components’i deneysel olarak işaretliyor; deneme @tanstack/react-start 1.169.4, @tanstack/react-router 1.170.46, nitro 3.0.260610-beta ve @vitejs/plugin-rsc 0.5.38 kullandı. Yukarıdaki barındırma sözleşmesi aynı; değişen, parçaların nerede durduğu.

Route’lar istemci bileşeni. Sunucu bileşenleri, loader’dan çağrılan ve @tanstack/react-start/rsc içindeki renderServerComponent(<X />) döndüren bir server function’dan geliyor. Layout atlaması layout route’unda staleTime: Infinity; varsayılan 0, her çocuk geçişinde layout’un server function’ını yeniden çalıştırıyor. Barındırma açısından her server function çağrısı bir origin isteği; bu yüzden staleTime, React Router’daki shouldRevalidate’in karşılığı olan ayar. Yeniden ziyaret edilen route’lar router cache’inden arka planda yenilenerek render ediliyor; ölçülen 6 ile 8 ms.

Nitro, React’in production build’ini .output içine bundle’lıyor; NODE_ENV tuzağı yok ve node_modules içinde yalnızca compression ile bağımlılıkları var. .output, index.js ve compression’dan oluşan deploy dizini yaklaşık 2,9 MB; React Router uygulamasında yaklaşık 45 MB. Web Functions’ta paket boyutuna karşı soğuk başlangıç ölçülmedi; bu fark burada başlangıç süresi hakkında bir şey söylemiyor.

// vite.config.ts
nitro({
  preset: 'node-middleware',
  serveStatic: true,
  // Bu preset'te başarısız olan ("Failed to fetch /about") prerender'ın yerine:
  // istek anında render et, CDN 5 dk tutsun; tarayıcı her seferinde yeniden doğrular.
  routeRules: { '/about/**': { headers: { 'cache-control': 'public, max-age=0, s-maxage=300' } } },
}),

node-middleware preset’i dinlemek yerine bir handler export ediyor; böylece bir index.js, AWS_LAMBDA_HTTP_ENDPOINT’i bağlayıp önüne sıkıştırma koyabiliyor. Nitro’nun Node adaptörü (srvx) res.end(callback) çağırıyor ve compression 1.8.2 o noktada düşüyor:

TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received function

Shim, yanıtı middleware’e vermeden önce argümanları normalize ediyor.

// srvx (Nitro'nun Node adaptörü) res.end(callback) çağırıyor; compression 1.8.2 end(chunk,
// encoding)'i yamalıyor ve Buffer.from(function) ile çöküyor. Argümanları normalize et, callback'i koru.
function withEndCallback(res) {
  const end = res.end;
  res.end = (chunk, encoding, cb) => {
    if (typeof chunk === "function") [cb, chunk] = [chunk, undefined];
    else if (typeof encoding === "function") [cb, encoding] = [encoding, undefined];
    if (cb) res.once("finish", cb);
    return end.call(res, chunk, encoding);
  };
}

Shim dışında, server function’lar ve prerender de React Router’dan farklı davrandı. Yalnızca bir istemci bileşeninin kullandığı server function’ın bir sunucu route modülünden de import edilmesi gerekiyor. Yoksa çağrı Error: Server function info not found for <id> ile HTTP 500 veriyor. node-middleware preset’iyle prerender Error: Failed to fetch /about: ile başarısız oldu. Yukarıdaki config bu yüzden onun yerine s-maxage taşıyan bir route rule kullanıyor.

Sonuncusu benchmark’ı etkiliyor. User-Agent bir bota benziyorsa TanStack, HTML’i göndermeden önce bütün Suspense sınırlarını bekliyor. AllViewerExceptHostHeader viewer’ın User-Agent’ını ilettiği için CloudFront User-Agent: Amazon CloudFront değerini yalnızca bu header’ı taşımayan isteklere koydu. UA’sız sağlık kontrolleri ve probe’lar bu yüzden yaklaşık 3,1 s’de tamamen buffer’lanmış bir sayfa gördü; tarayıcılar ise stream aldı ve CloudFront üzerinden ilk baytlar 41 ms’de geldi. User-Agent’ı ileten bir origin request policy yoksa, custom origin sayfasına göre CloudFront bu header’ı her istekte değiştiriyor. O durumda TanStack, tarayıcılar dahil her sayfayı buffer’lar. Header’ı iletin ve streaming’i tarayıcı User-Agent’ıyla ölçün.

Bu Kurulumun Geçerli Olduğu Sınır#

Bu kurulum, kullanıcıları çoğunlukla tek bir Region’da oturan ve kişiselleştirilmiş route’ları az sayıda olup private işaretlenmiş bir RSC uygulaması için geçerli. Şekli şu: o Region’da bir Web Function, yukarıdaki shim, yol ve query’ye göre anahtarlanan bir cache policy, 60 saniyelik origin keepalive ve root ile layout’larda shouldRevalidate. Override durumları tablodakiler. Yalnızca CloudFront üzerinden erişilebilmesi gereken bir origin OAC ile IamAuth istiyor; kıtalara yayılmış kullanıcılar bu denemenin test etmediği çok Region’lı endpoint’i istiyor; payload’ları küçük, CPU’ya bağlı bir fonksiyon uygulama içi sıkıştırmayı bırakabiliyor. Her deploy’dan sonra ilk koşturulacak şey, tarayıcı User-Agent’ıyla CloudFront üzerinden chunk zamanlama probe’u. Sıkıştırma ve framework ayarları streaming’i hata vermeden buffering’e çevirebiliyor.

Kaynaklar#

İlgili yazılar