diff --git a/examples/streaming/app.js b/examples/streaming/app.js new file mode 100644 index 0000000..cf1b636 --- /dev/null +++ b/examples/streaming/app.js @@ -0,0 +1,35 @@ +// Streaming SSR demo: the shell flushes immediately, the slow section +// streams in when its promise resolves. Run it: +// node examples/streaming/server.js +// +// This page is server-rendered only (no hydration script): async holes +// stream on the server, while client-side views must be synchronous. +// Serializing streamed state for hydration is bankai v10 territory. + +import choo from '@choojs/core' +import html from '@choojs/html' + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default function createApp ({ delay = 500 } = {}) { + const app = choo() + app.use((state) => { state.delay = delay }) + app.route('/', mainView) + app.route('*', mainView) + return app +} + +function mainView (state, emit) { + return html` + +

choo streams

+

This shell was flushed before the slow part finished.

+ ${slowSection(state)} + + ` +} + +async function slowSection (state) { + await wait(state.delay) + return html`
…and this arrived ${state.delay}ms later, same response.
` +} diff --git a/examples/streaming/server.js b/examples/streaming/server.js new file mode 100644 index 0000000..8e609ac --- /dev/null +++ b/examples/streaming/server.js @@ -0,0 +1,27 @@ +// Minimal streaming SSR server on plain Node http. +// node examples/streaming/server.js +// The same ReadableStream plugs into web-standard servers as +// `new Response(app.toStream('/'))` — Readable.fromWeb is just the +// Node http bridge. + +import { createServer } from 'node:http' +import { Readable } from 'node:stream' + +import createApp from './app.js' + +const PORT = process.env.PORT || 8080 + +createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.write('\n\nchoo streams\n') + + const body = Readable.fromWeb(createApp().toStream('/')) + body.pipe(res, { end: false }) + body.on('end', () => res.end('\n')) + body.on('error', (err) => { + console.error(err) + res.destroy() + }) +}).listen(PORT, () => { + console.log(`streaming on http://localhost:${PORT} — watch the slow section arrive`) +}) diff --git a/test/e2e/serve.js b/test/e2e/serve.js index b7493eb..cbc7913 100644 --- a/test/e2e/serve.js +++ b/test/e2e/serve.js @@ -7,7 +7,10 @@ import { readFile } from 'node:fs/promises' import { join, normalize, extname, dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import { Readable } from 'node:stream' + import createApp from '../../examples/counter/app.js' +import createStreamingApp from '../../examples/streaming/app.js' const root = normalize(join(dirname(fileURLToPath(import.meta.url)), '..', '..')) @@ -60,6 +63,18 @@ export function startServer () { return } + if (url.pathname === '/stream') { + const delay = Number(url.searchParams.get('delay')) || 500 + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + // padding nudges browsers to start parsing before the stream closes + res.write('\n\nchoo streams\n' + '\n') + const body = Readable.fromWeb(createStreamingApp({ delay }).toStream('/')) + body.pipe(res, { end: false }) + body.on('end', () => res.end('\n')) + body.on('error', () => res.destroy()) + return + } + let pathname = decodeURIComponent(url.pathname) if (pathname.endsWith('/')) pathname += 'index.html' const file = normalize(join(root, pathname)) diff --git a/test/e2e/streaming.test.js b/test/e2e/streaming.test.js new file mode 100644 index 0000000..f5cbd25 --- /dev/null +++ b/test/e2e/streaming.test.js @@ -0,0 +1,57 @@ +// Streaming SSR, proven at two levels: raw chunk timing over HTTP, and +// progressive DOM construction in real Chromium. + +import { test, before, after } from 'node:test' +import assert from 'node:assert' +import { chromium } from 'playwright' + +import { startServer } from './serve.js' + +const DELAY = 500 + +let browser, srv + +before(async () => { + srv = await startServer() + browser = await chromium.launch() +}) + +after(async () => { + await browser?.close() + await srv?.close() +}) + +test('HTTP level: shell bytes arrive before the slow section resolves', async () => { + const started = performance.now() + const res = await fetch(`${srv.origin}/stream?delay=${DELAY}`) + const decoder = new TextDecoder() + + let sawShellAt = null + let text = '' + for await (const chunk of res.body) { + text += decoder.decode(chunk, { stream: true }) + if (sawShellAt === null && text.includes('

choo streams

')) { + sawShellAt = performance.now() - started + assert.ok(!text.includes('id="slow"'), 'slow section not in the early bytes') + } + } + + assert.ok(sawShellAt !== null, 'shell was seen') + assert.ok(sawShellAt < DELAY, `shell arrived at ${sawShellAt.toFixed(0)}ms, before the ${DELAY}ms hole`) + assert.match(text, /id="slow"/, 'slow section arrived in the same response') + assert.match(text, /<\/html>/, 'document completed') +}) + +test('browser level: Chromium builds the shell DOM before the stream ends', async () => { + const page = await browser.newPage() + await page.goto(`${srv.origin}/stream?delay=${DELAY}`, { waitUntil: 'commit' }) + + // the shell must be in the DOM while the slow section is still absent + await page.waitForSelector('h1', { timeout: DELAY - 100 }) + assert.strictEqual(await page.$('#slow'), null, 'slow section not parsed yet') + + // then the same document grows the slow section without navigation + await page.waitForSelector('#slow', { timeout: DELAY * 4 }) + assert.match(await page.textContent('#slow'), /500ms later/) + await page.close() +})