TT Lab
Get started
Learn Learning paths Courses

SSR — The Server Draws First

Making Strings Safe

Continue in TT Lab

Goal

You do server rendering yourself as the work of building strings, and in doing so you block the three things that come out of it — XSS, hydration mismatch, and cache leaks.

What you will build

A single file, /root/work/ssr/render.mjs. You use no framework and no npm.

export contract
renderShell() A document shell that is valid even without data
escapeHtml(s) Replace five characters (& first)
render(state) Complete HTML containing the content. A pure function
renderState(state) A <script type="application/json"> state tag
renderStream(state) An async generator — flows the shell out first
cacheControl(state) The Cache-Control value depending on whether the user is logged in

state has this shape.

{ title: "제목", items: ["가", "나"], user: { name: "홍길동" } | null }

Check

cd /root/work/ssr
node -e "import('./render.mjs').then(m => console.log(m.render({title:'t',items:['a']})))"

Steps

  1. renderShell()
  2. render(state) — the content goes inside the HTML
  3. escapeHtml — & first
  4. renderState — prevent </script> from breaking out
  5. Purity — no Date.now/Math.random
  6. renderStream — shell first
  7. cacheControl
  8. Wrap-up → 08-notes.md

Notes

The step 5 grader swaps Date.now and Math.random so that they return a different value on every call, then renders twice and compares. If you are calling them inside the render, it shows up right away.

Start with the shell

In /root/work/ssr/render.mjs, export renderShell(). It returns, as a string, a document that is valid even without data, with <!doctype html>, <html lang>, <head>, and <body>.

mkdir -p /root/work/ssr. Because this is ESM, write export function renderShell() { ... }. This shell becomes the first piece sent out in streaming — as soon as the browser receives it, it goes off to fetch the CSS.

The content must be in the first HTML

Export render(state). state.title and state.items (an array of strings) must be actually present inside the HTML string.

The first response of CSR is just <div id="root"></div> — to crawlers and link previews, that is a blank page. Make each item an <li> and put it in.

Escape user data

Export escapeHtml(s) and use it inside render. You must replace five characters (& < > " '), and you must replace & first.

The grader tries values such as <img src=x onerror=alert(1)>. If you replace & later, an &lt; you have already produced is broken into &amp;lt; — the order is part of the rule.

Plant the initial state safely

Export renderState(state) to build a <script type="application/json" id="state"> tag. Even if the data contains </script>, the tag must not be cut short.

JSON.stringify(state).replace(/</g, '\u003C'). If you replace < with a Unicode escape, the JSON value stays the same while the tag is not cut short. When type="application/json", the browser does not execute it — the escaping is still needed even so.

Make the render function pure

Make render produce the same output for the same input, always. You must not call Date.now(), Math.random(), new Date(), or toLocaleString() inside it.

The grader swaps Date.now and Math.random so that they return a different value on every call, then renders twice and compares. If you need a time or an id, create it on the server and pass it in through state — that is the only way to eliminate hydration mismatch.

Flow the shell out first

Export renderStream(state) as an async generator. The first piece is the shell, and the pieces containing data come after it. There must be at least two pieces.

export async function* renderStream(state) { yield renderShell부분; ...; yield 본문; } (the placeholders are the shell part and the body). The first piece must contain <head and must not contain state.title — the point is that the shell does not wait for the data. The last piece must end with </html>.

Do not cache personalized pages

Export cacheControl(state). If state.user is present, return private, no-store; if not, return a value containing public and s-maxage.

A personalized response with public attached leads to the accident where the CDN gives A's page to B. It is safer to make private, no-store the default and open up only what may be made public.

Sum up the three

In 08-notes.md, write at least three lines. Cover why & must be replaced first in escaping, two causes of hydration mismatch, and what happens when you attach public to a personalized page.

The text must contain 이스케이프, 하이드레이션, and 캐시 (the Korean words for "escaping", "hydration", and "cache"). The third is the most expensive accident — by the time it is discovered, someone else's information has already gone out.