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()
+})