diff --git a/docs/streaming-ssr.md b/docs/streaming-ssr.md new file mode 100644 index 00000000..35d76040 --- /dev/null +++ b/docs/streaming-ssr.md @@ -0,0 +1,327 @@ +# Streaming SSR with Unhead (Experimental) + +> ⚠️ **Experimental Feature**: The streaming SSR API is experimental and may change in future versions. + +> 🎯 **Framework Support**: Currently only supports **Vue 3** and native Node.js streams. React and Solid.js support coming soon. + +Unhead provides experimental support for streaming SSR, allowing you to update head tags dynamically as your application streams to the client. This feature requires integration with framework-specific streaming renderers that support chunking control. + +## Overview + +Streaming SSR allows you to send HTML to the browser as soon as it's ready, rather than waiting for the entire page to render. With Unhead's streaming support, you can: + +- Update the document title as components load +- Add meta tags progressively +- Modify HTML/body attributes during streaming +- Deduplicate tags to prevent conflicts + +## Basic Usage + +### Vue 3 + +```typescript +import { createHead, streamAppWithUnhead } from '@unhead/vue/server' +import { renderToNodeStream } from 'vue/server-renderer' + +// Create a head instance per request +const head = createHead() + +// Your Vue app stream +const appStream = renderToNodeStream(app) + +// Stream with head management +const htmlStart = '' +const htmlEnd = '' + +for await (const chunk of streamAppWithUnhead(appStream, htmlStart, htmlEnd, head)) { + res.write(chunk) +} +res.end() +``` + +### React / Other Frameworks + +```typescript +// Use your framework's streaming renderer +import { renderToPipeableStream } from 'react-dom/server' +import { createHead, streamAppWithUnhead } from 'unhead/server' + +const head = createHead() +// ... setup and streaming logic +``` + +## Bot Detection Example + +A common pattern is to disable streaming for bots (search engines, social media crawlers) to ensure they receive complete metadata immediately. + +```typescript +import { createHead, renderSSRHead, streamAppWithUnhead } from '@unhead/vue/server' +import { isbot } from 'isbot' +import { renderToNodeStream, renderToString } from 'vue/server-renderer' + +export async function handleRequest(req, res) { + // Create a fresh head instance for each request + const head = createHead() + + // Set up your app with the head instance + const app = createApp({ + head, + // ... other setup + }) + + // Detect if the request is from a bot + const userAgent = req.headers['user-agent'] || '' + const isBot = isbot(userAgent) + + // Choose rendering strategy based on bot detection + if (isBot) { + // For bots: Use traditional SSR for complete HTML + await renderCompleteHTML(app, head, res) + } + else { + // For users: Use streaming for better performance + await renderStreamingHTML(app, head, res) + } +} + +async function renderCompleteHTML(app, head, res) { + // Render the complete app + const appHTML = await renderToString(app) + + // Get the complete head + const { headTags, bodyTags, htmlAttrs, bodyAttrs } = await renderSSRHead(head) + + // Send complete HTML + const html = ` + + + + ${headTags} + + + ${appHTML} + ${bodyTags} + +` + + res.status(200).type('html').send(html) +} + +async function renderStreamingHTML(app, head, res) { + const appStream = renderToNodeStream(app) + + const htmlStart = ` + + + + + +` + + const htmlEnd = `` + + res.status(200).type('html') + + // Stream the response + for await (const chunk of streamAppWithUnhead(appStream, htmlStart, htmlEnd, head)) { + res.write(chunk) + } + res.end() +} +``` + +## Vue 3 with Suspense + +Here's how to use streaming with Vue 3's Suspense feature: + +### 1. Create a HeadStream Component + +```vue + + +``` + +### 2. Use in Async Components + +```vue + + + + +``` + +### 3. App with Suspense + +```vue + + + + +``` + +## How It Works + +1. **Initial Chunk**: When the first chunk is processed, Unhead injects the initial head tags into the `` section +2. **Stream Markers**: Components can include `` markers that are replaced with JavaScript to update head tags +3. **Progressive Updates**: As components resolve, their head updates are applied client-side +4. **Deduplication**: Tags are deduplicated to prevent conflicts when multiple components update the same tags + +## Best Practices + +### 1. Create Head Instance Per Request + +Always create a new head instance for each request to avoid state contamination: + +```typescript +// ✅ Good +app.get('/', (req, res) => { + const head = createHead() + // ... use head for this request +}) + +// ❌ Bad - shared instance +const head = createHead() +app.get('/', (req, res) => { + // ... head is shared across requests! +}) +``` + +### 2. Consider SEO Impact + +While streaming improves user experience, consider these SEO factors: + +- Bots may not execute JavaScript for head updates +- Initial head tags should contain critical metadata +- Use bot detection to serve complete HTML when needed + +### 3. Error Handling + +Wrap streaming in try-catch blocks: + +```typescript +try { + for await (const chunk of streamAppWithUnhead(appStream, htmlStart, htmlEnd, head)) { + if (res.closed) + break + res.write(chunk) + } +} +catch (error) { + console.error('Streaming error:', error) + // Handle error appropriately +} +``` + +### 4. Performance Monitoring + +Monitor streaming performance: + +```typescript +const startTime = Date.now() +let chunkCount = 0 + +for await (const chunk of streamAppWithUnhead(appStream, htmlStart, htmlEnd, head)) { + chunkCount++ + res.write(chunk) +} + +console.log(`Streamed ${chunkCount} chunks in ${Date.now() - startTime}ms`) +``` + +## Limitations + +- Body position scripts (`bodyOpen`, `bodyClose`) only appear in final output +- Stream markers split across chunks won't be processed +- Requires client-side JavaScript for head updates + +## API Reference + +### `streamAppWithUnhead(appStream, htmlStart, htmlEnd, head)` + +Streams an SSR application with dynamic head management. + +**Parameters:** +- `appStream`: AsyncIterable - The app's render stream +- `htmlStart`: string - Initial HTML up to opening body tag +- `htmlEnd`: string - Closing HTML from closing body tag +- `head`: Unhead - The head instance for this request + +**Returns:** AsyncGenerator - Processed HTML chunks + +### `renderSSRStreamComponents(head, html)` + +Processes a single HTML chunk for head updates. + +**Parameters:** +- `head`: Unhead - The head instance +- `html`: string - HTML chunk to process + +**Returns:** Promise - Processed HTML + +## Examples + +- [Vue 3 Streaming SSR Example](/examples/vite-ssr-vue-streaming) +- [React 18 Streaming Example](/examples/vite-ssr-react-streaming) + +## Future Improvements + +- Better error recovery strategies +- Stream compression support +- More granular control over update timing +- WebSocket support for head updates diff --git a/examples/vite-ssr-vue-streaming/head-stream.js b/examples/vite-ssr-vue-streaming/head-stream.js deleted file mode 100644 index 4d174704..00000000 --- a/examples/vite-ssr-vue-streaming/head-stream.js +++ /dev/null @@ -1,46 +0,0 @@ -import { renderSSRHead } from '@unhead/vue/server' - -export async function headStream(res, vueStream, htmlStart, htmlEnd, head) { - res.status(200).set({ - 'Content-Type': 'text/html; charset=utf-8' - }) - - let bufferChunks = [] - let flushTimer = null - let isStreaming = false - - async function writeFirstChunk() { - const s = Buffer.from(bufferChunks).toString('utf8') - bufferChunks = [] - const headHtml = await renderSSRHead(head) - res.write((htmlStart.replace('', `${headHtml.headTags}`) + s)) - } - - for await (const chunk of vueStream) { - if (res.closed) break - - if (isStreaming) { - res.write(chunk) - continue - } - - bufferChunks.push(...chunk) - - if (flushTimer) clearTimeout(flushTimer) - - flushTimer = setTimeout(async () => { - isStreaming = true - if (bufferChunks.length > 0) { - await writeFirstChunk() - } - }, 3) // 3ms is an arbitrary choice as we figure it's not within a suspense boundary - } - - if (bufferChunks.length > 0) { - await writeFirstChunk() - } - - const headHtml = await renderSSRHead(head) - res.write((htmlEnd.replace('', `${headHtml.bodyTags}`))) - res.end() -} diff --git a/examples/vite-ssr-vue-streaming/package.json b/examples/vite-ssr-vue-streaming/package.json index 170e285e..49b26a94 100644 --- a/examples/vite-ssr-vue-streaming/package.json +++ b/examples/vite-ssr-vue-streaming/package.json @@ -12,6 +12,8 @@ "build:server:noExternal": "vite build --config vite.config.noexternal.js --ssr src/entry-server.js --outDir dist/server", "generate": "vite build --ssrManifest --outDir dist/static && npm run build:server && node prerender --experimental-json-modules ", "serve": "NODE_ENV=production node server", + "serve:bot-detection": "NODE_ENV=production node server-with-bot-detection", + "dev:bot-detection": "node server-with-bot-detection", "debug": "node --inspect-brk server" }, "dependencies": { diff --git a/examples/vite-ssr-vue-streaming/server.js b/examples/vite-ssr-vue-streaming/server.js index a5237404..6e787d2c 100644 --- a/examples/vite-ssr-vue-streaming/server.js +++ b/examples/vite-ssr-vue-streaming/server.js @@ -3,7 +3,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import express from 'express' -import { headStream } from "./head-stream.js"; +import { streamAppWithUnhead } from "@unhead/vue/server"; const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD @@ -83,11 +83,16 @@ export async function createServer( const { vueStream, head } = render(url) - const [htmlStart, htmlEnd] = template.split('') // - res.status(200).set({ 'Content-Type': 'text/html' }) - await headStream(res, vueStream, htmlStart, htmlEnd, head) + res.status(200).set({ 'Content-Type': 'text/html; charset=utf-8' }) + + const unheadStream = streamAppWithUnhead(vueStream, htmlStart, htmlEnd, head) + for await (const chunk of unheadStream) { + if (res.closed) break + res.write(chunk) + } + res.end() } catch (e) { vite && vite.ssrFixStacktrace(e) diff --git a/examples/vite-ssr-vue-streaming/src/App.vue b/examples/vite-ssr-vue-streaming/src/App.vue index 1cc3b8af..020f3218 100644 --- a/examples/vite-ssr-vue-streaming/src/App.vue +++ b/examples/vite-ssr-vue-streaming/src/App.vue @@ -12,7 +12,6 @@ useHead({ bodyAttrs: { style: 'overflow: hidden;', }, - title: 'hello world', script: [ { tagPosition: 'bodyClose', @@ -31,6 +30,9 @@ useHead({ +
+ v-once example +
diff --git a/examples/vite-ssr-vue-streaming/src/components/HeadStream.vue b/examples/vite-ssr-vue-streaming/src/components/HeadStream.vue new file mode 100644 index 00000000..931da852 --- /dev/null +++ b/examples/vite-ssr-vue-streaming/src/components/HeadStream.vue @@ -0,0 +1,14 @@ + diff --git a/examples/vite-ssr-vue-streaming/src/components/SlowComponent.vue b/examples/vite-ssr-vue-streaming/src/components/SlowComponent.vue index fa63d275..6a598442 100644 --- a/examples/vite-ssr-vue-streaming/src/components/SlowComponent.vue +++ b/examples/vite-ssr-vue-streaming/src/components/SlowComponent.vue @@ -1,21 +1,44 @@ -