diff --git a/.changeset/forty-lemons-heal.md b/.changeset/forty-lemons-heal.md new file mode 100644 index 000000000..8714ca7fa --- /dev/null +++ b/.changeset/forty-lemons-heal.md @@ -0,0 +1,5 @@ +--- +'@kitajs/fastify-html-plugin': patch +--- + +Fixed encapsulation diff --git a/.changeset/lemon-rules-suffer.md b/.changeset/lemon-rules-suffer.md new file mode 100644 index 000000000..281ab66c6 --- /dev/null +++ b/.changeset/lemon-rules-suffer.md @@ -0,0 +1,5 @@ +--- +'@kitajs/fastify-html-plugin': minor +--- + +Improved performance and async support diff --git a/packages/fastify-html-plugin/index.js b/packages/fastify-html-plugin/index.js index 08dc28b19..e239d78cc 100644 --- a/packages/fastify-html-plugin/index.js +++ b/packages/fastify-html-plugin/index.js @@ -8,58 +8,96 @@ if (!globalThis.SUSPENSE_ROOT) { require('@kitajs/html/suspense'); } +/** @type {import('./types/index').kAutoDoctype} */ +const kAutoDoctype = Symbol.for('fastify-kita-html.autoDoctype'); + /** * @type {import('fastify').FastifyPluginCallback< * import('./types').FastifyKitaHtmlOptions * >} */ -function fastifyKitaHtml(fastify, opts, next) { +function plugin(fastify, opts, next) { // Good defaults opts.autoDoctype ??= true; + fastify.decorateReply(kAutoDoctype, opts.autoDoctype); fastify.decorateReply('html', html); return next(); +} - /** @type {import('fastify').FastifyReply['html']} */ - function html(htmlStr) { - if (typeof htmlStr !== 'string') { - // Recursive promise handling, rejections should just rethrow - // just like as if it was a sync component error. - return htmlStr.then(html.bind(this)); - } +/** @type {import('fastify').FastifyReply['html']} */ +function html(htmlStr) { + // @ts-expect-error - generics break the type inference here + return typeof htmlStr === 'string' + ? handleHtml(htmlStr, this) + : handleAsyncHtml(htmlStr, this); +} - // prepends doctype if the html is a full html document - if (opts.autoDoctype && isTagHtml(htmlStr)) { - htmlStr = '' + htmlStr; - } +/** + * Simple helper that can be optimized by the JS engine to avoid having async await in the + * main flow + * + * @template {import('fastify').FastifyReply} R + * @param {Promise} promise + * @param {R} reply + * @returns {Promise} + */ +async function handleAsyncHtml(promise, reply) { + return handleHtml(await promise, reply); +} - this.type('text/html; charset=utf-8'); +/** + * @template {import('fastify').FastifyReply} R + * @param {string} htmlStr + * @param {R} reply + */ +function handleHtml(htmlStr, reply) { + // @ts-expect-error - prepends doctype if the html is a full html document + if (reply[kAutoDoctype] && isTagHtml(htmlStr)) { + htmlStr = '' + htmlStr; + } - // If no suspense component was used, this will not be defined. - const requestData = SUSPENSE_ROOT.requests.get(this.request.id); + reply.type('text/html; charset=utf-8'); - if (!requestData) { - return ( - this - // Nothing needs to be sent later, content length is known - .header('content-length', Buffer.byteLength(htmlStr)) - .send(htmlStr) - ); - } + // If no suspense component was used, this will not be defined. + const requestData = SUSPENSE_ROOT.requests.get(reply.request.id); - requestData.stream.push(htmlStr); + if (requestData === undefined) { + return ( + reply + // Should be safe to use .length instead of Buffer.byteLength here + .header('content-length', handleHtml.length) + .send(htmlStr) + ); + } - // Content-length is optional as long as the connection is closed after the response is done - // https://www.rfc-editor.org/rfc/rfc7230#section-3.3.3 + requestData.stream.push(htmlStr); - return this.send(requestData.stream); - } + // Content-length is optional as long as the connection is closed after the response is done + // https://www.rfc-editor.org/rfc/rfc7230#section-3.3.3 + return reply.send(requestData.stream); } -module.exports = fp(fastifyKitaHtml, { +const fastifyKitaHtml = fp(plugin, { fastify: '4.x', name: '@kitajs/fastify-html-plugin' }); -module.exports.default = fastifyKitaHtml; -module.exports.fastifyKitaHtml = fastifyKitaHtml; + +/** + * These export configurations enable JS and TS developers to consume + * + * @kitajs/fastify-html-plugin in whatever way best suits their needs. Some examples of + * supported import syntax includes: + * + * - `const fastifyKitaHtml = require('@kitajs/fastify-html-plugin')` + * - `const { fastifyKitaHtml } = require('@kitajs/fastify-html-plugin')` + * - `import * as fastifyKitaHtml from '@kitajs/fastify-html-plugin'` + * - `import { fastifyKitaHtml } from '@kitajs/fastify-html-plugin'` + * - `import fastifyKitaHtml from '@kitajs/fastify-html-plugin'` + */ +module.exports = fastifyKitaHtml; +module.exports.default = fastifyKitaHtml; // supersedes fastifyKitaHtml.default = fastifyKitaHtml +module.exports.fastifyKitaHtml = fastifyKitaHtml; // supersedes fastifyKitaHtml.fastifyKitaHtml = fastifyKitaHtml + +module.exports.kAutoDoctype = kAutoDoctype; diff --git a/packages/fastify-html-plugin/lib/is-tag-html.js b/packages/fastify-html-plugin/lib/is-tag-html.js index 5d7df7c53..bac32d39f 100644 --- a/packages/fastify-html-plugin/lib/is-tag-html.js +++ b/packages/fastify-html-plugin/lib/is-tag-html.js @@ -10,7 +10,7 @@ module.exports.isTagHtml = function isTagHtml(value) { // remove whitespace from the start of the string .trimStart() // get the first 5 characters - .slice(0, 5) + .substring(0, 5) // compare to ` { assert.deepStrictEqual(res.json(), { statusCode: 500, error: 'Internal Server Error', - message: 'htmlStr.then is not a function' + message: 'value.trimStart is not a function' }); }); @@ -69,7 +69,7 @@ describe('reply.html()', () => { assert.deepStrictEqual(res.json(), { statusCode: 500, error: 'Internal Server Error', - message: 'htmlStr.then is not a function' + message: 'value.trimStart is not a function' }); }); }); diff --git a/packages/fastify-html-plugin/types/index.d.ts b/packages/fastify-html-plugin/types/index.d.ts index eeef67f2c..13d94d66c 100644 --- a/packages/fastify-html-plugin/types/index.d.ts +++ b/packages/fastify-html-plugin/types/index.d.ts @@ -2,6 +2,12 @@ import type { FastifyPluginCallback } from 'fastify'; declare module 'fastify' { interface FastifyReply { + /** + * This gets assigned to every reply instance. You can manually change this value to + * `false` if you want to "hand pick" when or when not to add the doctype. + */ + [fastifyKitaHtml.kAutoDoctype]: boolean; + /** * **Synchronously** waits for the component tree to resolve and sends it at once to * the browser. @@ -31,7 +37,10 @@ declare module 'fastify' { * @param html The HTML to send. * @returns The response. */ - html(this: this, html: JSX.Element): this | Promise; + html( + this: this, + html: H + ): H extends Promise ? Promise : void; } } @@ -61,6 +70,12 @@ declare namespace fastifyKitaHtml { export const fastifyKitaHtml: FastifyKitaHtmlPlugin; + /** + * This gets assigned to every reply instance. You can manually change this value to + * `false` if you want to "hand pick" when or when not to add the doctype. + */ + export const kAutoDoctype: unique symbol; + export { fastifyKitaHtml as default }; }