From 54bc81fa0bf8b592cfef6918c7fceb6d8c51dc51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:42:12 +0000 Subject: [PATCH 1/3] fix(ssr): drop encoding and hop-by-hop headers from piped responses A render entry that returns a fetch()ed Response hands the middleware an already decoded body, but pipeResponse() copied every header verbatim, including content-encoding and the compressed content-length. Browsers then failed to decode the body or truncated it. Only the HTML branch filtered these headers. Both branches now share copyHeaders(), which drops content-encoding, content-length, transfer-encoding and the hop-by-hop headers (plus any named in Connection), and keeps every Set-Cookie value via getSetCookie(). The dev-server tests proxy a gzip upstream through the middleware for a JSON and an HTML response. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018Ju3jbgag99iuxndoJTrgM --- README.md | 5 ++- src/ssr.js | 65 +++++++++++++++++++++++++------- test/vite.test.ts | 94 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2a5fdb2..8590334 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,10 @@ one warning for each of them. The SSR entry exports `render(request: Request, ctx)`. `ctx` holds `url`, `template`, `nonce`, `dev` and `server`. `render` returns an HTML string, a -`Response`, or `null` to fall through to the next middleware. +`Response`, or `null` to fall through to the next middleware. A `Response` +keeps its status and headers, except `content-encoding`, `content-length` and +hop-by-hop headers: its body is read decoded (as `fetch()` returns it), so +those no longer describe what the middleware sends. Every `Set-Cookie` is kept. ## Options diff --git a/src/ssr.js b/src/ssr.js index 7b97dd3..6eff730 100644 --- a/src/ssr.js +++ b/src/ssr.js @@ -18,8 +18,55 @@ import { readTextIfExists } from './utils.js'; /** Default server entry (relative to the project root). */ export const DEFAULT_SSR_ENTRY = 'src/entry-server.ts'; -/** Headers that must not be copied from a buffered `Response`. */ -const DROPPED_HEADERS = new Set(['content-length', 'content-encoding', 'transfer-encoding']); +/** + * Headers never copied from a render result. A `Response` body is always read + * decoded (`fetch()` undoes `content-encoding` but keeps the header), so the + * upstream encoding and length no longer describe it, and hop-by-hop headers + * (RFC 9110 §7.6.1) belong to the upstream connection, not to this one. Node + * sets the length or chunked framing for the body it actually writes. + */ +const DROPPED_HEADERS = new Set([ + 'content-encoding', + 'content-length', + 'transfer-encoding', + 'connection', + 'keep-alive', + 'proxy-connection', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'upgrade', +]); + +/** + * Copies status-independent headers of a `Response` to a Node response, + * without encoding, length and hop-by-hop headers (including any that the + * `Connection` header names). Every `Set-Cookie` value is kept separately. + * + * @param {Headers} headers - The `Response` headers. + * @param {import('node:http').ServerResponse} res - Node response. + */ +const copyHeaders = (headers, res) => { + const listed = new Set( + (headers.get('connection') ?? '') + .split(',') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean) + ); + headers.forEach((value, name) => { + if (name === 'set-cookie' || DROPPED_HEADERS.has(name) || listed.has(name)) return; + res.setHeader(name, value); + }); + const single = headers.get('set-cookie'); + const cookies = + typeof headers.getSetCookie === 'function' + ? headers.getSetCookie() + : single === null + ? [] + : [single]; + if (cookies.length > 0) res.setHeader('set-cookie', cookies); +}; /** * Whether the middleware should try to render this request. @@ -69,16 +116,12 @@ const isResponse = (value) => * Writes a `Response` body to a Node response, streaming when possible. * * @param {Response} response - The Web response. - * @param {any} res - Node response. + * @param {import('node:http').ServerResponse} res - Node response. * @param {boolean} head - Whether the request was `HEAD`. */ const pipeResponse = async (response, res, head) => { res.statusCode = response.status; - response.headers.forEach((value, name) => { - if (name !== 'set-cookie') res.setHeader(name, value); - }); - const cookies = response.headers.getSetCookie?.() ?? []; - if (cookies.length > 0) res.setHeader('set-cookie', cookies); + copyHeaders(response.headers, res); if (head || !response.body) { res.end(); return; @@ -163,11 +206,7 @@ export const createSsrPlugin = (options, shared) => { if (contentType.includes('text/html') && transformHtml) { const html = await transform(await result.text()); res.statusCode = result.status; - result.headers.forEach((value, name) => { - if (!DROPPED_HEADERS.has(name) && name !== 'set-cookie') res.setHeader(name, value); - }); - const cookies = result.headers.getSetCookie?.() ?? []; - if (cookies.length > 0) res.setHeader('set-cookie', cookies); + copyHeaders(result.headers, res); res.end(req.method === 'HEAD' ? undefined : html); return; } diff --git a/test/vite.test.ts b/test/vite.test.ts index bba185f..def5171 100644 --- a/test/vite.test.ts +++ b/test/vite.test.ts @@ -1,5 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { createServer as createHttpServer, get as httpGet, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; +import { gzipSync } from 'node:zlib'; import { build, createLogger, createServer, type Rollup, type ViteDevServer } from 'vite'; import bqueryVite from '../vite.js'; import { createProject, SAMPLE_FILES, waitFor, type TempProject } from './helpers'; @@ -24,6 +26,10 @@ export async function render(request, ctx) { return renderToResponse(template, { title: 'Rendered by bQuery' }, { context: ssr }); } if (pathname === '/boom') throw new Error('render exploded'); + if (pathname === '/proxied') { + // fetch() decodes the gzip body but keeps the upstream's encoding headers. + return fetch(ssr.url.searchParams.get('upstream')); + } if (pathname === '/api-like') { return new Response(JSON.stringify({ ok: true }), { status: 201, @@ -109,14 +115,68 @@ describe('vite build', () => { }); }); +/** A Node response read without decoding: status, raw headers and body bytes. */ +const rawGet = (url: string, accept = 'text/html') => + new Promise<{ + status: number; + headers: Record; + body: string; + }>((resolve, reject) => { + httpGet(url, { headers: { accept } }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf8'), + }) + ); + res.on('error', reject); + }).on('error', reject); + }); + +/** An upstream that always answers with a gzip-compressed body. */ +const startGzipUpstream = async () => { + const bodies: Record = { + '/data.json': { type: 'application/json', body: JSON.stringify({ hello: 'gzip'.repeat(64) }) }, + '/page.html': { + type: 'text/html; charset=utf-8', + body: `

${'upstream '.repeat(64)}

`, + }, + }; + const upstream: Server = createHttpServer((req, res) => { + const entry = bodies[req.url ?? '']; + if (!entry) { + res.statusCode = 404; + res.end(); + return; + } + const gz = gzipSync(entry.body); + res.writeHead(200, { + 'content-type': entry.type, + 'content-encoding': 'gzip', + 'content-length': String(gz.length), + 'x-upstream': 'yes', + 'set-cookie': ['a=1; Path=/', 'b=2; Path=/; HttpOnly'], + }); + res.end(gz); + }); + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)); + const { port } = upstream.address() as AddressInfo; + return { upstream, url: `http://127.0.0.1:${port}`, bodies }; +}; + describe('vite dev server', () => { let project: TempProject; let server: ViteDevServer; let base: string; + let gzip: Awaited>; const errors: string[] = []; const routeUpdates: string[] = []; beforeAll(async () => { + gzip = await startGzipUpstream(); project = createProject({ ...SAMPLE_FILES, 'index.html': INDEX_HTML, @@ -150,6 +210,7 @@ describe('vite dev server', () => { afterAll(async () => { await server.close(); + gzip.upstream.close(); project.cleanup(); }); @@ -202,6 +263,39 @@ describe('vite dev server', () => { expect(await res.json()).toEqual({ ok: true }); }); + it('drops stale encoding headers from a fetched (decoded) Response', async () => { + const res = await rawGet( + `${base}/proxied?upstream=${encodeURIComponent(`${gzip.url}/data.json`)}` + ); + expect(res.status).toBe(200); + expect(res.headers['content-encoding']).toBeUndefined(); + const length = res.headers['content-length']; + if (length !== undefined) expect(Number(length)).toBe(Buffer.byteLength(res.body)); + expect(res.body).toBe(gzip.bodies['/data.json'].body); + expect(res.headers['content-type']).toBe('application/json'); + expect(res.headers['x-upstream']).toBe('yes'); + expect(res.headers['set-cookie']).toEqual(['a=1; Path=/', 'b=2; Path=/; HttpOnly']); + // A decoding client (the browser) reads it too. + const decoded = await fetch( + `${base}/proxied?upstream=${encodeURIComponent(`${gzip.url}/data.json`)}`, + { headers: { accept: 'text/html' } } + ); + expect(await decoded.json()).toEqual(JSON.parse(gzip.bodies['/data.json'].body)); + }); + + it('drops stale encoding headers from a fetched HTML Response', async () => { + const res = await rawGet( + `${base}/proxied?upstream=${encodeURIComponent(`${gzip.url}/page.html`)}` + ); + expect(res.status).toBe(200); + expect(res.headers['content-encoding']).toBeUndefined(); + const length = res.headers['content-length']; + if (length !== undefined) expect(Number(length)).toBe(Buffer.byteLength(res.body)); + expect(res.body).toContain('upstream upstream'); + expect(res.body).toContain('/@vite/client'); + expect(res.headers['set-cookie']).toEqual(['a=1; Path=/', 'b=2; Path=/; HttpOnly']); + }); + it('falls through to Vite when render returns null', async () => { const res = await get('/fallthrough'); expect(res.status).toBe(200); From 52764a2065a5004448a43c5050635b38ab5e04b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:42:41 +0000 Subject: [PATCH 2/3] fix(views): leave view imports with a query to other plugins resolveId() rewrote every *.view.html import to `?bq-view` and dropped the importer's query, so in Vite `import url from './home.view.html?url'` (or ?raw, ?inline) returned the compiled view module instead of a URL or the markup. The plugin now resolves and loads only bare template imports and ids whose query holds just its own marker, ignoring the `import` and `t=` parts the Vite dev server adds for transport and HMR. Anything else is left to the bundler. Tests cover ?raw and ?url in a real Vite build and in the dev server, the bare import, and recompiling an edited view on an HMR re-import. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018Ju3jbgag99iuxndoJTrgM --- README.md | 4 ++- index.d.ts | 3 +- src/views.js | 46 ++++++++++++++++++++++--- test/unit.test.ts | 10 ++++++ test/vite.test.ts | 86 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8590334..6038587 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,9 @@ A template import becomes the module the view compiler emits: it calls `registerCompiledExpressions()` when it runs, and its default export is the markup. Import the template before you `mount()` it. Expressions the compiler cannot handle keep working through the runtime evaluator, and the plugin emits -one warning for each of them. +one warning for each of them. Only bare template imports are compiled: an +import with a query of its own (`./home.view.html?raw`, `?url`, `?inline`) is +left to the bundler and its other plugins. The SSR entry exports `render(request: Request, ctx)`. `ctx` holds `url`, `template`, `nonce`, `dev` and `server`. `render` returns an HTML string, a diff --git a/index.d.ts b/index.d.ts index 618b963..01e5767 100644 --- a/index.d.ts +++ b/index.d.ts @@ -24,7 +24,8 @@ export interface ViewsOptions { /** Files never compiled implicitly (the query flag still opts them in). */ exclude?: FilePattern | FilePattern[]; /** - * Query flag that opts any `.html` import into compilation. + * Query flag that opts any `.html` import into compilation. Imports carrying + * any other query (`?raw`, `?url`, …) are never compiled. * @default 'bq-view' */ query?: string; diff --git a/src/views.js b/src/views.js index 174cf0b..ccd22a1 100644 --- a/src/views.js +++ b/src/views.js @@ -1,7 +1,8 @@ /** * View precompilation: turns `*.view.html` (or any `*.html?bq-view`) imports * into the module emitted by `@bquery/bquery/view/compiler`, plus a default - * export holding the template markup. + * export holding the template markup. Imports with a query of their own + * (`?raw`, `?url`, `?inline`, …) are left to the bundler and other plugins. * * @module @bquery/unplugin/views */ @@ -10,7 +11,15 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { compileViews, emitModule } from '@bquery/bquery/view/compiler'; -import { cleanId, createFilter, hasQueryFlag, toJsLiteral, toPosix, warnVia } from './utils.js'; +import { + cleanId, + createFilter, + hasQueryFlag, + queryOf, + toJsLiteral, + toPosix, + warnVia, +} from './utils.js'; /** Default glob for files treated as view templates without a query flag. */ export const DEFAULT_VIEW_INCLUDE = ['**/*.view.html']; @@ -18,6 +27,33 @@ export const DEFAULT_VIEW_INCLUDE = ['**/*.view.html']; /** Default query flag that opts any `.html` import into view compilation. */ export const DEFAULT_VIEW_QUERY = 'bq-view'; +/** + * Query parts the Vite dev server adds for its own transport (`?import` on + * non-JS URLs, `?t=` on HMR re-imports). They do not ask for another module. + * + * @param {string} part - One `&`-separated query part. + * @returns {boolean} Whether Vite adds it itself. + */ +const isViteTransportPart = (part) => part === 'import' || /^t=\d+$/.test(part); + +/** + * Whether an id's query is empty or holds nothing but the view marker (and + * Vite's transport parts). Any other query, such as Vite's `?raw`, `?url` or + * `?inline`, asks for something other than the compiled view, so those ids are + * left to the bundler and its other plugins. + * + * @param {string} id - A module specifier or id. + * @param {string} flag - The view query flag. + * @returns {boolean} Whether the plugin may compile it. + */ +export const isViewQuery = (id, flag) => + queryOf(id) + .split('&') + .every( + (part) => + part === '' || part === flag || part.startsWith(`${flag}=`) || isViteTransportPart(part) + ); + /** * Builds the JavaScript module for one template. * @@ -95,7 +131,7 @@ export const createViewsPlugin = (options, shared) => { resolveId(source, importer, resolveOptions) { if (source.startsWith('\0') || resolveOptions?.isEntry) return null; const file = cleanId(source); - if (!file.endsWith('.html')) return null; + if (!file.endsWith('.html') || !isViewQuery(source, query)) return null; const flagged = hasQueryFlag(source, query); const abs = resolveTemplate(file, importer); if (!abs) return null; @@ -107,10 +143,10 @@ export const createViewsPlugin = (options, shared) => { return `${toPosix(abs)}?${query}`; }, loadInclude(id) { - return hasQueryFlag(id, query) && cleanId(id).endsWith('.html'); + return hasQueryFlag(id, query) && isViewQuery(id, query) && cleanId(id).endsWith('.html'); }, async load(id) { - if (!hasQueryFlag(id, query)) return null; + if (!hasQueryFlag(id, query) || !isViewQuery(id, query)) return null; const file = cleanId(id); this.addWatchFile?.(file); const template = await readFile(file, 'utf8'); diff --git a/test/unit.test.ts b/test/unit.test.ts index 4b57f62..7df3e2f 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -17,6 +17,7 @@ import bqueryRspack from '../rspack.js'; import bqueryVite from '../vite.js'; import bqueryWebpack from '../webpack.js'; import { createFilter, globToRegExp, toJsLiteral, writeIfChanged } from '../src/utils.js'; +import { isViewQuery } from '../src/views.js'; import { createProject, SAMPLE_FILES } from './helpers'; const names = (plugins: Array<{ name: string }>) => plugins.map((plugin) => plugin.name); @@ -157,6 +158,15 @@ describe('generators', () => { expect(stats.total).toBe(1); }); + it('compiles only bare view imports or the view marker', () => { + for (const id of ['a.view.html', 'a.html?bq-view', 'a.html?import&bq-view&t=123']) { + expect(isViewQuery(id, 'bq-view')).toBe(true); + } + for (const id of ['a.view.html?raw', 'a.view.html?url', 'a.html?bq-view&inline']) { + expect(isViewQuery(id, 'bq-view')).toBe(false); + } + }); + it('adds HMR handling and honours the import specifier', () => { const { code } = buildViewModule('

', { importSpecifier: 'my-view' }, true); expect(code).toContain('from "my-view"'); diff --git a/test/vite.test.ts b/test/vite.test.ts index def5171..ce5fea9 100644 --- a/test/vite.test.ts +++ b/test/vite.test.ts @@ -167,6 +167,66 @@ const startGzipUpstream = async () => { return { upstream, url: `http://127.0.0.1:${port}`, bodies }; }; +describe('vite build: view imports with a query', () => { + let project: TempProject; + let output: Rollup.RollupOutput['output']; + + beforeAll(async () => { + project = createProject({ + 'src/main.js': [ + "import template from './views/home.view.html';", + "import raw from './views/raw.view.html?raw';", + "import url from './views/url.view.html?url';", + 'console.log(template, raw, url);', + ].join('\n'), + 'src/views/home.view.html': '

', + 'src/views/raw.view.html': '', + 'src/views/url.view.html': '', + }); + const result = (await build({ + root: project.root, + configFile: false, + logLevel: 'silent', + plugins: [bqueryVite()], + build: { + write: false, + minify: false, + assetsInlineLimit: 0, + rollupOptions: { input: project.path('src/main.js'), external: [/^@bquery\//] }, + }, + })) as Rollup.RollupOutput; + output = result.output; + }); + + afterAll(() => project.cleanup()); + + const js = () => + output + .filter((file): file is Rollup.OutputChunk => file.type === 'chunk') + .map((chunk) => chunk.code) + .join('\n'); + + it('still compiles bare *.view.html imports', () => { + expect(js()).toContain('registerCompiledExpressions'); + expect(js()).toContain('__bq_ctx.count + 1'); + }); + + it('returns the raw markup for ?raw', () => { + expect(js()).toMatch(/