Skip to content

feat: streaming support #537

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open

feat: streaming support #537

wants to merge 1 commit into from

Conversation

harlan-zw
Copy link
Collaborator

@harlan-zw harlan-zw commented Apr 1, 2025

πŸ”— Linked issue

#396

❓ Type of change

  • πŸ“– Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • πŸ‘Œ Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

πŸ“š Description

WIP Support for streaming.

  • TypeScript - should just work
  • πŸ”¨ Vue - Development working fully, likely some edge cases and production issues to look at
  • πŸ”¨ React - App shell working, but don't know enough about <Suspense> to know how it's meant to work, seems okay in development
  • Solid.js
  • Svelte
  • Angular

How it works

We hijack framework stream response prepending and appending the head data in the HTML template.

Outside of the app shell (for suspense) we need to inject code to insert the head tags. When we hydrate our app it should swap out any of the tags without issue.

Both of these are handled by the function renderSSRStreamComponents(unhead, htmlPartial), which can augment Unhead tags into partial HTML.

A utility is provided streamAppWithUnhead which can take an app stream and augment it for Unhead, this is practical for Vue and TypeScript but likely not for frameworks with more fine-tuned streaming such as React and Solid.js

Copy link
Contributor

github-actions bot commented Apr 1, 2025

Bundle Size Analysis

File Size Gzipped Size Size Diff Gzipped Size Diff
Client 10.7 kB (10996 B) 4.5 kB (4581 B) 0.00% (0 B) 0.00% ( 0 B)
Server 8 kB (8174 B) 3.4 kB (3467 B) 0.00% (0 B) 0.00% ( 0 B)

Comment on lines +39 to +101
app.use('*all', async (req, res) => {
try {
const url = req.originalUrl.replace(base, '')

/** @type {string} */
let template
/** @type {import('./src/entry-server.ts').render} */
let render
if (!isProduction) {
// Always read fresh template in development
template = await fs.readFile('./index.html', 'utf-8')
template = await vite.transformIndexHtml(url, template)
render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render
} else {
template = templateHtml
render = (await import('./dist/server/entry-server.js')).render
}

let didError = false

const { stream, head } = render(url, {
onShellError() {
res.status(500)
res.set({ 'Content-Type': 'text/html' })
res.send('<h1>Something went wrong</h1>')
},
async onShellReady() {
res.status(didError ? 500 : 200)
res.set({
'Content-Type': 'text/html',
})

const transformStream = new Transform({
async transform(chunk, encoding, callback) {
res.write(await renderSSRStreamComponents(head, new TextDecoder().decode(chunk)))
callback()
},
})

const [htmlStart, htmlEnd] = template.split(`<!--app-html-->`)
res.write(await renderSSRStreamComponents(head, htmlStart))

transformStream.on('finish', async () => {
res.end(await renderSSRStreamComponents(head, htmlEnd))
})

stream.pipe(transformStream)
},
onError(error) {
didError = true
console.error(error)
},
})

setTimeout(() => {
stream.abort()
}, ABORT_DELAY)
} catch (e) {
vite?.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)
}
})

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a file system access
, but is not rate-limited.

Copilot Autofix

AI 4 months ago

To fix the problem, we need to introduce rate limiting to the Express application to prevent potential DoS attacks. We can use the express-rate-limit package to achieve this. The rate limiter will be applied to all incoming requests to ensure that the server is protected from excessive requests.

  1. Install the express-rate-limit package.
  2. Import the express-rate-limit package in the server file.
  3. Configure the rate limiter with appropriate settings (e.g., maximum number of requests per time window).
  4. Apply the rate limiter middleware to the Express application.
Suggested changeset 2
examples/vite-ssr-react-streaming-ts/server.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/examples/vite-ssr-react-streaming-ts/server.js b/examples/vite-ssr-react-streaming-ts/server.js
--- a/examples/vite-ssr-react-streaming-ts/server.js
+++ b/examples/vite-ssr-react-streaming-ts/server.js
@@ -4,2 +4,9 @@
 import { renderSSRStreamComponents } from 'unhead/server'
+import rateLimit from 'express-rate-limit'
+
+// Rate limiter configuration
+const limiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 100, // limit each IP to 100 requests per windowMs
+})
 
@@ -19,2 +26,5 @@
 
+// Apply rate limiter to all requests
+app.use(limiter)
+
 // Add Vite or respective production middlewares
EOF
@@ -4,2 +4,9 @@
import { renderSSRStreamComponents } from 'unhead/server'
import rateLimit from 'express-rate-limit'

// Rate limiter configuration
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
})

@@ -19,2 +26,5 @@

// Apply rate limiter to all requests
app.use(limiter)

// Add Vite or respective production middlewares
examples/vite-ssr-react-streaming-ts/package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/examples/vite-ssr-react-streaming-ts/package.json b/examples/vite-ssr-react-streaming-ts/package.json
--- a/examples/vite-ssr-react-streaming-ts/package.json
+++ b/examples/vite-ssr-react-streaming-ts/package.json
@@ -18,3 +18,4 @@
     "react-dom": "^19.0.0",
-    "sirv": "^3.0.1"
+    "sirv": "^3.0.1",
+    "express-rate-limit": "^7.5.0"
   },
EOF
@@ -18,3 +18,4 @@
"react-dom": "^19.0.0",
"sirv": "^3.0.1"
"sirv": "^3.0.1",
"express-rate-limit": "^7.5.0"
},
This fix introduces these dependencies
Package Version Security advisories
express-rate-limit (npm) 7.5.0 None
Copilot is powered by AI and may make mistakes. Always verify output.
} catch (e) {
vite?.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)

Check warning

Code scanning / CodeQL

Information exposure through a stack trace Medium

This information exposed to the user depends on
stack trace information
.

Copilot Autofix

AI 4 months ago

To fix the problem, we need to ensure that the stack trace is not sent back to the user. Instead, we should log the stack trace on the server and send a generic error message to the user. This can be achieved by modifying the catch block to log the error and send a generic message.

  • Modify the catch block starting at line 96 to log the error stack trace and send a generic error message.
  • Ensure that the error stack trace is logged using console.error or a similar logging mechanism.
Suggested changeset 1
examples/vite-ssr-react-streaming-ts/server.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/examples/vite-ssr-react-streaming-ts/server.js b/examples/vite-ssr-react-streaming-ts/server.js
--- a/examples/vite-ssr-react-streaming-ts/server.js
+++ b/examples/vite-ssr-react-streaming-ts/server.js
@@ -97,4 +97,4 @@
     vite?.ssrFixStacktrace(e)
-    console.log(e.stack)
-    res.status(500).end(e.stack)
+    console.error(e.stack)
+    res.status(500).end('An error occurred')
   }
EOF
@@ -97,4 +97,4 @@
vite?.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)
console.error(e.stack)
res.status(500).end('An error occurred')
}
Copilot is powered by AI and may make mistakes. Always verify output.
} catch (e) {
vite?.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)

Check warning

Code scanning / CodeQL

Exception text reinterpreted as HTML Medium

Exception text
is reinterpreted as HTML without escaping meta-characters.

Copilot Autofix

AI 4 months ago

To fix the problem, we need to ensure that any error messages sent in the HTTP response are properly sanitized or escaped to prevent XSS vulnerabilities. The best way to fix this issue is to use a library like he (HTML entities) to escape the error message before sending it in the response.

  1. Install the he library to handle HTML escaping.
  2. Import the he library in the file.
  3. Use the he.escape function to escape the error message before sending it in the response.
Suggested changeset 2
examples/vite-ssr-react-streaming-ts/server.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/examples/vite-ssr-react-streaming-ts/server.js b/examples/vite-ssr-react-streaming-ts/server.js
--- a/examples/vite-ssr-react-streaming-ts/server.js
+++ b/examples/vite-ssr-react-streaming-ts/server.js
@@ -4,2 +4,3 @@
 import { renderSSRStreamComponents } from 'unhead/server'
+import he from 'he'
 
@@ -98,3 +99,3 @@
     console.log(e.stack)
-    res.status(500).end(e.stack)
+    res.status(500).end(he.escape(e.stack))
   }
EOF
@@ -4,2 +4,3 @@
import { renderSSRStreamComponents } from 'unhead/server'
import he from 'he'

@@ -98,3 +99,3 @@
console.log(e.stack)
res.status(500).end(e.stack)
res.status(500).end(he.escape(e.stack))
}
examples/vite-ssr-react-streaming-ts/package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/examples/vite-ssr-react-streaming-ts/package.json b/examples/vite-ssr-react-streaming-ts/package.json
--- a/examples/vite-ssr-react-streaming-ts/package.json
+++ b/examples/vite-ssr-react-streaming-ts/package.json
@@ -18,3 +18,4 @@
     "react-dom": "^19.0.0",
-    "sirv": "^3.0.1"
+    "sirv": "^3.0.1",
+    "he": "^1.2.0"
   },
EOF
@@ -18,3 +18,4 @@
"react-dom": "^19.0.0",
"sirv": "^3.0.1"
"sirv": "^3.0.1",
"he": "^1.2.0"
},
This fix introduces these dependencies
Package Version Security advisories
he (npm) 1.2.0 None
Copilot is powered by AI and may make mistakes. Always verify output.
@harlan-zw
Copy link
Collaborator Author

@birkskyum in the works fyi

@birkskyum
Copy link

birkskyum commented Apr 11, 2025

@harlan-zw sounds good - looking forward to react and solid support for this, as streaming is a must-have for adoption.

@harlan-zw
Copy link
Collaborator Author

This is mostly working for react I just don't fully understand how the suspense boundaries are being resolved and can't hook in

@MatthieuStadelmann
Copy link

MatthieuStadelmann commented Jun 30, 2025

Hi @harlan-zw!
Any update on the status of this PR?

@harlan-zw harlan-zw mentioned this pull request Jul 6, 2025
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants