Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,16 @@ 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
`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

Expand Down
3 changes: 2 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 52 additions & 13 deletions src/ssr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
46 changes: 41 additions & 5 deletions src/views.js
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -10,14 +11,49 @@ 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'];

/** 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.
*
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand Down
18 changes: 15 additions & 3 deletions test/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -147,16 +148,27 @@ describe('generators', () => {
});

it('handles bq-on statement lists with any supported @bquery/bquery', async () => {
// `a(); b()` in bq-on compiles with bQuery releases after 1.17.2; 1.17.2
// skips it (runtime fallback + warning). Either way the module is valid.
// `a(); b()` in bq-on compiles with bQuery releases after 1.17.2 (which
// register it in a separate statements map); 1.17.2 skips it (runtime
// fallback + warning). Either way the module is valid.
const handler = '$event.preventDefault(); add()';
const { code, stats } = buildViewModule(`<form bq-on:submit="${handler}"></form>`, {}, false);
await transform(code, { format: 'esm', loader: 'js' });
const skipped = stats.skipped.some((entry) => entry.expression === handler);
expect(code.includes(`${JSON.stringify(handler)}: (__bq_ctx) =>`)).toBe(!skipped);
const registration = code.slice(0, code.indexOf('const template'));
expect(registration.includes(JSON.stringify(handler))).toBe(!skipped);
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('<p bq-text="a"></p>', { importSpecifier: 'my-view' }, true);
expect(code).toContain('from "my-view"');
Expand Down
Loading
Loading