-
-
Notifications
You must be signed in to change notification settings - Fork 59
v3.0.0 #620
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
base: main
Are you sure you want to change the base?
Conversation
Bundle Size Analysis
|
* fix(unhead)!: sync resolve tag engine * chore: clean up * chore: fix types
* perf(unhead)!: composable `resolveTags()` * chore: tidy up * chore: tidy up * perf: reduce codde
* fix!: drop deprecations * chore: sync * chore: sync * chore: progress * chore: progress
* perf(unhead): client only capo sorting * chore: build * chore: exports * chore: capo * chore: simplify
* fix!: sync `renderDOMHead` * doc: missing
* fix!: sync `renderDOMHead` * doc: missing * fix!: sync `renderSSRHead()` * chore: conflict
* fix!: sync `renderDOMHead` * doc: missing * fix!: sync `renderSSRHead()` * chore: conflict * fix!: pluggable render functions * fix!: pluggable render functions
* fix: switch to `HookableCore` * fix: pure unhead core * chore: test
| app.use('/{*path}', async (req, res) => { | ||
| try { | ||
| const url = req.originalUrl | ||
|
|
||
| let template, render | ||
| if (!isProd) { | ||
| template = fs.readFileSync(resolve('index.html'), 'utf-8') | ||
| template = await vite.transformIndexHtml(url, template) | ||
| render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render | ||
| } | ||
| else { | ||
| template = indexProd | ||
| render = (await import('./dist/server/entry-server.js')).render | ||
| } | ||
|
|
||
| const stream = render(url, template) | ||
|
|
||
| res.status(200).set({ 'Content-Type': 'text/html; charset=utf-8' }) | ||
| const reader = stream.getReader() | ||
|
|
||
| while (true) { | ||
| const { done, value } = await reader.read() | ||
| if (done) break | ||
| if (res.closed) break | ||
| res.write(value) | ||
| } | ||
| res.end() | ||
| } | ||
| catch (e) { | ||
| vite && vite.ssrFixStacktrace(e) | ||
| console.log(e.stack) | ||
| res.status(500).end(e.stack) | ||
| } | ||
| }) |
Check failure
Code scanning / CodeQL
Missing rate limiting High
a file system access
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, this should be fixed by adding a rate‑limiting middleware to the Express app so that the expensive SSR route cannot be invoked arbitrarily often. A standard way is to use a well‑known package like express-rate-limit, configure a reasonable window and maximum request count, and apply it to the relevant route (or globally, if appropriate).
For this file, the minimal change without altering existing behavior is:
- Import
express-rate-limitat the top ofexamples/vite-ssr-solidjs-streaming/server.js, alongside the existing imports. - Define a limiter (e.g., 100 requests per 15 minutes per IP, as in the example) after creating
app. - Apply the limiter only to the expensive catch‑all route by adding it as middleware in
app.use('/{*path}', limiter, async (req, res) => { ... }). This way, dev/prod logic, SSR behavior, and existing middleware order remain unchanged; only the number of requests per client to this route is constrained.
Concretely:
- Add
import rateLimit from 'express-rate-limit'after the existingexpressimport. - After
const app = express(), defineconst ssrLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }). - Change the route at line 54 from
app.use('/{*path}', async (req, res) => { ... })toapp.use('/{*path}', ssrLimiter, async (req, res) => { ... }).
-
Copy modified line R6 -
Copy modified lines R23-R26 -
Copy modified line R59
| @@ -3,6 +3,7 @@ | ||
| import path from 'node:path' | ||
| import { fileURLToPath } from 'node:url' | ||
| import express from 'express' | ||
| import rateLimit from 'express-rate-limit' | ||
|
|
||
| const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD | ||
|
|
||
| @@ -19,6 +20,10 @@ | ||
| : '' | ||
|
|
||
| const app = express() | ||
| const ssrLimiter = rateLimit({ | ||
| windowMs: 15 * 60 * 1000, | ||
| max: 100, | ||
| }) | ||
|
|
||
| /** @type {import('vite').ViteDevServer} */ | ||
| let vite | ||
| @@ -51,7 +56,7 @@ | ||
| ) | ||
| } | ||
|
|
||
| app.use('/{*path}', async (req, res) => { | ||
| app.use('/{*path}', ssrLimiter, async (req, res) => { | ||
| try { | ||
| const url = req.originalUrl | ||
|
|
-
Copy modified lines R19-R20
| @@ -16,7 +16,8 @@ | ||
| "@unhead/solid-js": "workspace:*", | ||
| "compression": "^1.8.1", | ||
| "express": "^5.2.1", | ||
| "solid-js": "^1.9.10" | ||
| "solid-js": "^1.9.10", | ||
| "express-rate-limit": "^8.2.1" | ||
| }, | ||
| "devDependencies": { | ||
| "@playwright/test": "^1.57.0", |
| Package | Version | Security advisories |
| express-rate-limit (npm) | 8.2.1 | None |
| app.use('/{*path}', async (req, res) => { | ||
| try { | ||
| const url = req.originalUrl | ||
|
|
||
| let template, render | ||
| if (!isProd) { | ||
| template = fs.readFileSync(resolve('index.html'), 'utf-8') | ||
| template = await vite.transformIndexHtml(url, template) | ||
| render = (await vite.ssrLoadModule('/src/entry-server.ts')).render | ||
| } | ||
| else { | ||
| template = indexProd | ||
| render = (await import('./dist/server/entry-server.js')).render | ||
| } | ||
|
|
||
| const stream = await render(url, template) | ||
|
|
||
| res.status(200).set({ 'Content-Type': 'text/html; charset=utf-8' }) | ||
| const reader = stream.getReader() | ||
|
|
||
| while (true) { | ||
| const { done, value } = await reader.read() | ||
| if (done) break | ||
| if (res.closed) break | ||
| res.write(value) | ||
| } | ||
| res.end() | ||
| } | ||
| catch (e) { | ||
| vite && vite.ssrFixStacktrace(e) | ||
| console.log(e.stack) | ||
| res.status(500).end(e.stack) | ||
| } | ||
| }) |
Check failure
Code scanning / CodeQL
Missing rate limiting High
a file system access
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
To fix this, introduce a rate-limiting middleware using a well-known library such as express-rate-limit, and apply it specifically to the expensive route at app.use('/{*path}', ...) (or globally if desired). This controls how many requests a client can make in a time window, mitigating denial-of-service risks from repeated filesystem and SSR operations.
The best minimal-impact change within the shown file is:
- Import
express-rate-limitat the top ofexamples/vite-ssr-svelte-streaming/server.js. - Define a limiter instance (e.g., 100 requests per 15 minutes per IP) near where the Express app is created, after
const app = express(). - Apply this limiter to the expensive route by passing it as middleware before the existing async handler, i.e., change
app.use('/{*path}', async (req, res) => { ... })toapp.use('/{*path}', limiter, async (req, res) => { ... }).
This preserves existing functionality and structure; only the addition of the middleware and its import are needed.
-
Copy modified line R6 -
Copy modified lines R24-R28 -
Copy modified line R60
| @@ -3,6 +3,7 @@ | ||
| import path from 'node:path' | ||
| import { fileURLToPath } from 'node:url' | ||
| import express from 'express' | ||
| import rateLimit from 'express-rate-limit' | ||
|
|
||
| const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD | ||
|
|
||
| @@ -20,6 +21,11 @@ | ||
|
|
||
| const app = express() | ||
|
|
||
| const ssrLimiter = rateLimit({ | ||
| windowMs: 15 * 60 * 1000, // 15 minutes | ||
| max: 100, // limit each IP to 100 SSR requests per window | ||
| }) | ||
|
|
||
| /** @type {import('vite').ViteDevServer} */ | ||
| let vite | ||
| if (!isProd) { | ||
| @@ -51,7 +57,7 @@ | ||
| ) | ||
| } | ||
|
|
||
| app.use('/{*path}', async (req, res) => { | ||
| app.use('/{*path}', ssrLimiter, async (req, res) => { | ||
| try { | ||
| const url = req.originalUrl | ||
|
|
-
Copy modified lines R18-R19
| @@ -15,7 +15,8 @@ | ||
| "@unhead/svelte": "workspace:*", | ||
| "compression": "^1.8.1", | ||
| "express": "^5.2.1", | ||
| "sirv": "^3.0.2" | ||
| "sirv": "^3.0.2", | ||
| "express-rate-limit": "^8.2.1" | ||
| }, | ||
| "devDependencies": { | ||
| "@playwright/test": "^1.57.0", |
| Package | Version | Security advisories |
| express-rate-limit (npm) | 8.2.1 | None |
| catch (e) { | ||
| vite && 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
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, never send raw exception messages or stack traces directly to the browser as HTML. For user-facing responses, either (a) send a generic error page/message that does not include user input, or (b) if you must include details, escape/encode the text appropriately (for example, HTML-escape it, or serve it with a safe content type like text/plain).
For this specific code, the safest and simplest change without altering core functionality is:
- Stop writing
e.stackdirectly as HTML. - Instead, respond with a generic error message to the client (for example,
Internal Server Error) and continue logging the full stack trace to the server console. - Optionally, if you still want to expose some detail (e.g., in dev), you can encode the stack trace before sending it or change the content type to
text/plain. Since we should not change surrounding behavior more than necessary and avoid adding new configuration toggles, the minimal secure fix is to replaceres.status(500).end(e.stack)with a generic constant string response such asres.status(500).end('Internal Server Error').
Concretely:
- In
examples/vite-ssr-solidjs-streaming/server.js, within thecatch (e)block around line 82, keepvite && vite.ssrFixStacktrace(e)andconsole.log(e.stack)as-is, but change the final response so it no longer includese.stack. No new imports or helper functions are required.
-
Copy modified line R85
| @@ -82,7 +82,7 @@ | ||
| catch (e) { | ||
| vite && vite.ssrFixStacktrace(e) | ||
| console.log(e.stack) | ||
| res.status(500).end(e.stack) | ||
| res.status(500).end('Internal Server Error') | ||
| } | ||
| }) | ||
|
|
| catch (e) { | ||
| vite && 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
stack trace information
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, the fix is to avoid sending the stack trace (or detailed error object) to the client, and instead log it on the server while returning a generic error message and status code to the user. This preserves debuggability without exposing internal implementation details.
Specifically for this file, we should:
- Keep server-side logging (
console.logor similar) of the full error/stack trace. - Replace
res.status(500).end(e.stack)with a generic message likeres.status(500).end('Internal Server Error')(or localized/alternative wording) that does not depend oneore.stack. - Optionally slightly improve logging by logging both the error and its stack, but without changing behavior observed by the client.
All required functionality can be implemented within examples/vite-ssr-solidjs-streaming/server.js without new imports. The only change is in the catch block of the app.use('/{*path}', ...) handler around lines 82–86.
-
Copy modified line R85
| @@ -82,7 +82,7 @@ | ||
| catch (e) { | ||
| vite && vite.ssrFixStacktrace(e) | ||
| console.log(e.stack) | ||
| res.status(500).end(e.stack) | ||
| res.status(500).end('Internal Server Error') | ||
| } | ||
| }) | ||
|
|
| catch (e) { | ||
| vite && 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
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, to fix this class of issue you should avoid sending raw exception messages or stack traces directly in an HTML response. For production, it’s safer to send a generic error page or message that contains no user-controllable data. For development, if you really need to expose the error content to the browser, you must HTML‑escape (encode) it so that any <, >, &, quotes, etc. are rendered as text instead of being interpreted as HTML or JavaScript.
The best fix here without changing existing functionality too much is:
- In the
catchblock, keep logging the full stack trace to the server console (console.log(e.stack)). - Change the HTTP response so that it no longer sends
e.stackdirectly. Instead:- In production (
isProd === true), send a generic error message (e.g.,"Internal Server Error"). - In non‑production, either:
- send an HTML‑escaped version of
e.stack, or - for maximum simplicity and safety, also send a generic message.
- send an HTML‑escaped version of
- In production (
- Implement a small HTML-escaping helper within this file if you want to preserve error content in dev. This avoids adding new dependencies and keeps the change localized.
Concretely, in examples/vite-ssr-svelte-streaming/server.js:
- Add a simple
escapeHtmlhelper function near the top of the file. - Replace
res.status(500).end(e.stack)with something like:const msg = isProd ? 'Internal Server Error' : escapeHtml(String(e.stack || e));res.status(500).set({ 'Content-Type': 'text/html; charset=utf-8' }).end(msg);
- Make sure the catch block doesn’t introduce any new tainted pathway (i.e., only escapes or discards exception content).
-
Copy modified lines R9-R17 -
Copy modified lines R93-R100
| @@ -6,6 +6,15 @@ | ||
|
|
||
| const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD | ||
|
|
||
| function escapeHtml(str) { | ||
| return String(str) | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, ''') | ||
| } | ||
|
|
||
| export async function createServer( | ||
| root = process.cwd(), | ||
| isProd = process.env.NODE_ENV === 'production', | ||
| @@ -81,8 +90,14 @@ | ||
| } | ||
| catch (e) { | ||
| vite && vite.ssrFixStacktrace(e) | ||
| console.log(e.stack) | ||
| res.status(500).end(e.stack) | ||
| console.log(e && e.stack ? e.stack : e) | ||
| const message = isProd | ||
| ? 'Internal Server Error' | ||
| : escapeHtml(e && e.stack ? e.stack : String(e)) | ||
| res | ||
| .status(500) | ||
| .set({ 'Content-Type': 'text/html; charset=utf-8' }) | ||
| .end(message) | ||
| } | ||
| }) | ||
|
|
| catch (e) { | ||
| vite && 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
stack trace information
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, the fix is to stop sending full stack traces to the client and instead send a generic error message while logging the stack trace on the server. The server logs retain the detail needed for debugging without exposing it over HTTP.
For this specific file, the best minimal change is inside the catch (e) block of the app.use('/{*path}', ...) handler. We should:
- Keep
vite && vite.ssrFixStacktrace(e)as-is (it adjusts stack traces for server-side logging and debugging). - Keep logging the error stack to the server console (or potentially improve logging later), but not include it in the HTTP response.
- Replace
res.status(500).end(e.stack)with a response that sends a generic message such as"Internal Server Error"or"An error occurred"without any stack/implementation details.
No new imports or external libraries are strictly required. All changes are localized to the existing catch block in examples/vite-ssr-svelte-streaming/server.js, around lines 82–86.
-
Copy modified line R85
| @@ -82,7 +82,7 @@ | ||
| catch (e) { | ||
| vite && vite.ssrFixStacktrace(e) | ||
| console.log(e.stack) | ||
| res.status(500).end(e.stack) | ||
| res.status(500).end('Internal Server Error') | ||
| } | ||
| }) | ||
|
|
Fixes issue where patches called before first render (e.g., in Vue's onMounted) were lost due to timing between patch and render. Changes: - Replace `_dirty` flag with cache invalidation via `delete entry._tags` - Defer client-side patches using `_pending` until next render cycle - Track `_originalInput` for SSR class side-effect pre-registration - Add streaming client parity for hydration tracking
* chore: progress * perf(client): reduce bundle size by ~30 bytes gzip - Minify internal property names (_t, _e, _p, _s for DOM state) - Simplify conditional logic and reduce code duplication - Fix unused regex capturing group in dedupe.ts - Update test to use renamed internal field
…636) When `patch()` is called during or after a render cycle, the render may complete and set `dirty = false` before the debounced render fires. This caused the debounced render to skip processing `_pending` patches. The fix checks for pending patches as a fallback when `dirty` is false, ensuring reactive updates are always rendered. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Branch to track Unhead v3 that will introduce support for streaming and numerous performance and DX improvmeents.
PRs
useHead()type narrowing #627resolveTags()#622renderDOMHead()#628renderSSRHead()#629render()function #630Performance
Bundle
✨ 11.2% smaller client
Bench
e2e benchmark
simple benchmark
✨ ~32% faster for mid-size site