From e8e6dbecc8691ec05c94d77e71c4cb1db0db501e Mon Sep 17 00:00:00 2001 From: Matt Weber <1062734+mweberxyz@users.noreply.github.com> Date: Fri, 22 Mar 2024 11:39:30 -0400 Subject: [PATCH] feat: viewAsync reply decorator Closes fastify/point-of-view#394 Closes fastify/point-of-view#412 --- README.md | 327 +++++++++++++++++++--------------- benchmark/fastify-ejs-view.js | 6 + benchmark/fastify.js | 2 +- index.js | 49 +++-- test/test-ejs-async.js | 68 +++++++ test/test-ejs.js | 267 +++++++++++++++++++++++++++ test/test-handlebars.js | 38 ++++ types/index.d.ts | 3 + 8 files changed, 600 insertions(+), 160 deletions(-) create mode 100644 benchmark/fastify-ejs-view.js diff --git a/README.md b/README.md index fb01e97..84f61b3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Templates rendering plugin support for Fastify. -`@fastify/view` decorates the reply interface with the `view` method for managing view engines, which can be used to render templates responses. +`@fastify/view` decorates the reply interface with the `viewAsync` and `view` methods for managing view engines, which can be used to render templates responses. Currently supports the following templates engines: @@ -49,61 +49,70 @@ npm i @fastify/view ## Quick start -`fastify.register` is used to register @fastify/view. By default, It will decorate the `reply` object with a `view` method that takes at least two arguments: +This example will render the template using the EJS engine and provide a variable `name` to be used inside the template: -- the template to be rendered -- the data that should be available to the template during rendering - -This example will render the template and provide a variable `text` to be used inside the template: +```html + + + +
+ +Hello, <%= name %>!
+ + +``` ```js -const fastify = require("fastify")(); +// index.js: +const fastify = require("fastify")() +const fastifyView = require("@fastify/view") -fastify.register(require("@fastify/view"), { +fastify.register(fastifyView, { engine: { - ejs: require("ejs"), - }, -}); + ejs: require("ejs") + } +}) fastify.get("/", (req, reply) => { - reply.view("/templates/index.ejs", { text: "text" }); -}); + return reply.viewAsync("index.ejs", { name: "User" }); +}) fastify.listen({ port: 3000 }, (err) => { if (err) throw err; console.log(`server listening on ${fastify.server.address().port}`); -}); +}) ``` -If your handler function is asynchronous, make sure to return the result - otherwise this will result in an `FST_ERR_PROMISE_NOT_FULFILLED` error: +## Recent Changes -```js -// This is an async function -fastify.get("/", async (req, reply) => { - // We are awaiting a function result - const t = await something(); +### `viewAsync` vs `view` - // Note the return statement - return reply.view("/templates/index.ejs", { text: "text" }); -}); -``` +As of version X.X.X, `reply.viewAsync` is added as a replacement for `reply.view` and `fastify.view`. -## Configuration +The behavior of `reply.view` is to immediately send the HTML response as soon as rendering is completed, or immediately send a 500 response with error if encountered, short-circuiting fastify's error handling hooks, whereas `reply.viewAsync` returns a promise that either resolves to the rendered HTML, or rejects on any errors. `fastify.view` has no mechanism for providing request-global variables, if needed. `reply.viewAsync` can be used in both sync and async handlers. -`fastify.register(<%= text %>
+``` + +```js +// index.js: +fastify.get('/', (req, reply) => { + const data = { text: "Hello!"} + reply.view('template.ejs', data, { layout: 'layout.ejs' }) +}) +``` + +### Providing a layout on render +**Please note:** Global layouts and providing layouts on render are mutually exclusive. They can not be mixed. + +```js +fastify.get('/', (req, reply) => { + const data = { text: "Hello!"} + reply.view('template.ejs', data, { layout: 'layout.ejs' }) +}) +``` + +## Setting request-global variables +Sometimes, several templates should have access to the same request-specific variables. E.g. when setting the current username. + +If you want to provide data, which will be depended on by a request and available in all views, you have to add property `locals` to `reply` object, like in the example below: + +```js +fastify.addHook("preHandler", function (request, reply, done) { + reply.locals = { + text: getTextFromRequest(request), // it will be available in all views + }; + + done(); +}); +``` -The `fastify` object is decorated the same way as `reply` and allows you to just render a view into a variable instead of sending the result back to the browser: +Properties from `reply.locals` will override those from `defaultContext`, but not from `data` parameter provided to `reply.view(template, data)` function. + +## Rendering the template into a variable +The `fastify` object is decorated the same way as `reply` and allows you to just render a view into a variable (without request-global variables) instead of sending the result back to the browser: ```js // Promise based, using async/await @@ -136,6 +209,9 @@ fastify.view("/templates/index.ejs", { text: "text" }, (err, html) => { }); ``` +If called within a request hook and you need request-global variables, see [Migrating from view to viewAsync](#migrating-from-view-to-viewAsync). + + ## Registering multiple engines Registering multiple engines with different configurations is supported. They are distinguished via their `propertyName`: @@ -164,36 +240,6 @@ fastify.get("/desktop", (req, reply) => { }); ``` -## Providing a layout on render - -@fastify/view supports layouts for **EJS**, **Handlebars**, **Eta** and **doT**. -These engines also support providing a layout on render. - -**Please note:** Global layouts and providing layouts on render are mutually exclusive. They can not be mixed. - -```js -fastify.get('/', (req, reply) => { - reply.view('index-for-layout.ejs', data, { layout: 'layout.html' }) -}) -``` - -## Setting request-global variables -Sometimes, several templates should have access to the same request-specific variables. E.g. when setting the current username. - -If you want to provide data, which will be depended on by a request and available in all views, you have to add property `locals` to `reply` object, like in the example below: - -```js -fastify.addHook("preHandler", function (request, reply, done) { - reply.locals = { - text: getTextFromRequest(request), // it will be available in all views - }; - - done(); -}); -``` - -Properties from `reply.locals` will override those from `defaultContext`, but not from `data` parameter provided to `reply.view(template, data)` function. - ## Minifying HTML on render To utilize [`html-minifier-terser`](https://www.npmjs.com/package/html-minifier-terser) in the rendering process, you can add the option `useHtmlMinifier` with a reference to `html-minifier-terser`, @@ -246,31 +292,6 @@ fastify.get("/test", (req, reply) => { ## Engine-specific settings - - ### Mustache To use partials in mustache you will need to pass the names and paths in the options parameter: @@ -730,52 +751,6 @@ fastify.get('/', (req, reply) => { }) ``` - - ## Miscellaneous ### Using @fastify/view as a dependency in a fastify-plugin @@ -798,10 +773,82 @@ fastify.view.clearCache(); +### Migrating from `view` to `viewAsync` + +#### Sync handler +Previously: +```js +fastify.get('/', (req, reply) => { + reply.view('index.ejs', { text: 'text' }) +}) +``` +Now: +```js +fastify.get('/', (req, reply) => { + return reply.viewAsync('index.ejs', { text: 'text' }) +}) +``` +#### Async handler +Previously: +```js +// This is an async function +fastify.get("/", async (req, reply) => { + const data = await something(); + reply.view("/templates/index.ejs", { data }); + return +}) +``` + +Now: +```js +// This is an async function +fastify.get("/", async (req, reply) => { + const data = await something(); + return reply.viewAsync("/templates/index.ejs", { data }); +}) +``` +#### fastify.view (when called inside a route hook) +Previously: +```js +// Promise based, using async/await +fastify.get("/", async (req, reply) => { + const html = await fastify.view("/templates/index.ejs", { text: "text" }); + return html +}) +``` +```js +// Callback based +fastify.get("/", (req, reply) => { + fastify.view("/templates/index.ejs", { text: "text" }, (err, html) => { + if(err) { + reply.send(err) + } + else { + reply.type("application/html").send(html) + } + }); +}) +``` +Now: +```js +// Promise based, using async/await +fastify.get("/", (req, reply) => { + const html = await fastify.viewAsync("/templates/index.ejs", { text: "text" }); + return html +}) +``` +```js +fastify.get("/", (req, reply) => { + fastify.view("/templates/index.ejs", { text: "text" }) + .then((html) => reply.type("application/html").send(html)) + .catch((err) => reply.send(err)) + }); +}) +``` + ## Note -By default views are served with the mime type 'text/html; charset=utf-8', -but you can specify a different value using the type function of reply, or by specifying the desired charset in the property 'charset' in the options object given to the plugin. +By default views are served with the mime type `text/html`, with the charset specified in options. You can specify a different `Content-Type` header using `reply.type`. ## Acknowledgements diff --git a/benchmark/fastify-ejs-view.js b/benchmark/fastify-ejs-view.js new file mode 100644 index 0000000..5329e58 --- /dev/null +++ b/benchmark/fastify-ejs-view.js @@ -0,0 +1,6 @@ +'use strict' + +require('./setup.js')({ + engine: { ejs: require('ejs') }, + route: (req, reply) => { reply.view('index.ejs', { text: 'text' }) } +}) diff --git a/benchmark/fastify.js b/benchmark/fastify.js index 5329e58..5eb394b 100644 --- a/benchmark/fastify.js +++ b/benchmark/fastify.js @@ -2,5 +2,5 @@ require('./setup.js')({ engine: { ejs: require('ejs') }, - route: (req, reply) => { reply.view('index.ejs', { text: 'text' }) } + route: (req, reply) => { return reply.viewAsync('index.ejs', { text: 'text' }) } }) diff --git a/index.js b/index.js index 368ea21..04a2d4f 100644 --- a/index.js +++ b/index.js @@ -32,6 +32,7 @@ async function fastifyView (fastify, opts) { } const charset = opts.charset || 'utf-8' const propertyName = opts.propertyName || 'view' + const asyncPropertyName = opts.asyncPropertyName || `${propertyName}Async` const engine = opts.engine[type] const globalOptions = opts.options || {} const templatesDir = resolveTemplateDir(opts) @@ -106,6 +107,28 @@ async function fastifyView (fastify, opts) { const renderer = renders[type] ? renders[type] : renders._default + async function asyncRender (page) { + if (!page) { + throw new Error('Missing page') + } + + let result = await renderer.apply(this, arguments) + + if (minify && !isPathExcludedMinification(this)) { + result = await minify(result, globalOptions.htmlMinifierOptions) + } + + if (!this.getHeader('Content-Type')) { + this.header('Content-Type', 'text/html; charset=' + charset) + } + + return result + } + + const fakeRequest = { + getHeader: () => true + } + function viewDecorator (page) { const args = Array.from(arguments) @@ -114,11 +137,7 @@ async function fastifyView (fastify, opts) { done = args.pop() } - let promise = !page ? Promise.reject(new Error('Missing page')) : renderer.apply(this, args) - - if (minify) { - promise = promise.then((result) => minify(result, globalOptions.htmlMinifierOptions)) - } + const promise = asyncRender.apply(fakeRequest, args) if (typeof done === 'function') { promise.then(done.bind(null, null), done) @@ -134,27 +153,19 @@ async function fastifyView (fastify, opts) { fastify.decorate(propertyName, viewDecorator) - fastify.decorateReply(propertyName, async function (page) { - if (!page) { - this.send(new Error('Missing page')) - } + fastify.decorateReply(propertyName, async function (page, data, opts) { try { - const result = await renderer.apply(this, arguments) - if (!this.getHeader('Content-Type')) { - this.header('Content-Type', 'text/html; charset=' + charset) - } - - if (minify && !isPathExcludedMinification(this)) { - this.send(await minify(result, globalOptions.htmlMinifierOptions)) - } else { - this.send(result) - } + const html = await asyncRender.call(this, page, data, opts) + this.send(html) } catch (err) { this.send(err) } + return this }) + fastify.decorateReply(asyncPropertyName, asyncRender) + if (!fastify.hasReplyDecorator('locals')) { fastify.decorateReply('locals', null) diff --git a/test/test-ejs-async.js b/test/test-ejs-async.js index 35b24cf..019aed5 100644 --- a/test/test-ejs-async.js +++ b/test/test-ejs-async.js @@ -506,3 +506,71 @@ test('reply.view with promise error', t => { }) }) }) + +test('reply.viewAsync with ejs engine and async: true (global option)', t => { + t.plan(6) + const fastify = Fastify() + const ejs = require('ejs') + + fastify.register(require('../index'), { + engine: { ejs }, + options: { async: true }, + templates: 'templates' + }) + + fastify.get('/', async (req, reply) => { + return reply.viewAsync('ejs-async.ejs') + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, async (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(await ejs.render(fs.readFileSync('./templates/ejs-async.ejs', 'utf8'), {}, { async: true }), body.toString()) + fastify.close() + }) + }) +}) + +test('reply.viewAsync with ejs engine, async: true (global option), and html-minifier-terser', t => { + t.plan(6) + const fastify = Fastify() + const ejs = require('ejs') + + fastify.register(require('../index'), { + engine: { ejs }, + options: { + async: true, + useHtmlMinifier: minifier, + htmlMinifierOptions: minifierOpts + }, + templates: 'templates' + }) + + fastify.get('/', (req, reply) => { + return reply.viewAsync('ejs-async.ejs') + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, async (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(await minifier.minify(await ejs.render(fs.readFileSync('./templates/ejs-async.ejs', 'utf8'), {}, { async: true }), minifierOpts), body.toString()) + fastify.close() + }) + }) +}) diff --git a/test/test-ejs.js b/test/test-ejs.js index 019c375..0fc0d6e 100644 --- a/test/test-ejs.js +++ b/test/test-ejs.js @@ -1251,3 +1251,270 @@ test('reply.view with ejs engine and failed call to render when onError hook def }) }) }) + +test('reply.viewAsync with ejs engine - sync handler', t => { + t.plan(6) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + ejs + } + }) + + fastify.get('/', async (req, reply) => { + return reply.viewAsync('templates/index.ejs', data) + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(ejs.render(fs.readFileSync('./templates/index.ejs', 'utf8'), data), body.toString()) + fastify.close() + }) + }) +}) + +test('reply.viewAsync with ejs engine - async handler', t => { + t.plan(6) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + ejs + } + }) + + fastify.get('/', (req, reply) => { + return reply.viewAsync('templates/index.ejs', data) + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(ejs.render(fs.readFileSync('./templates/index.ejs', 'utf8'), data), body.toString()) + fastify.close() + }) + }) +}) + +test('reply.viewAsync should return 500 if layout is missing on render', t => { + t.plan(3) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + fastify.register(require('../index'), { + engine: { + ejs + }, + root: path.join(__dirname, '../templates') + }) + + fastify.get('/', (req, reply) => { + return reply.viewAsync('index-for-layout.ejs', data, { layout: 'non-existing-layout.html' }) + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 500) + fastify.close() + }) + }) +}) + +test('reply.viewAsync should allow errors to be handled by custom error handler', t => { + t.plan(7) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + fastify.register(require('../index'), { + engine: { + ejs + }, + root: path.join(__dirname, '../templates') + }) + + fastify.get('/', (req, reply) => { + return reply.viewAsync('index-for-layout.ejs', data, { layout: 'non-existing-layout.html' }) + }) + + fastify.setErrorHandler((err, request, reply) => { + t.ok(err instanceof Error) + t.same(reply.getHeader('Content-Type'), null) + return 'something went wrong' + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.headers['content-type'], 'text/plain; charset=utf-8') + t.equal(response.statusCode, 200) + t.equal('something went wrong', body.toString()) + fastify.close() + }) + }) +}) + +test('reply.viewAsync with ejs engine and custom propertyName', t => { + t.plan(6) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + ejs + }, + propertyName: 'render' + }) + + fastify.get('/', async (req, reply) => { + return reply.renderAsync('templates/index.ejs', data) + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(ejs.render(fs.readFileSync('./templates/index.ejs', 'utf8'), data), body.toString()) + fastify.close() + }) + }) +}) + +test('reply.viewAsync with ejs engine and custom asyncPropertyName', t => { + t.plan(6) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + ejs + }, + asyncPropertyName: 'viewAsPromise' + }) + + fastify.get('/', async (req, reply) => { + return reply.viewAsPromise('templates/index.ejs', data) + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(ejs.render(fs.readFileSync('./templates/index.ejs', 'utf8'), data), body.toString()) + fastify.close() + }) + }) +}) + +test('reply.viewAsync with ejs engine and custom asyncPropertyName and custom propertyName', t => { + t.plan(11) + const fastify = Fastify() + const ejs = require('ejs') + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + ejs + }, + asyncPropertyName: 'renderPromise', + propertyName: 'oldRenderSend' + }) + + fastify.get('/asyncPropertyName', async (req, reply) => { + return reply.renderPromise('templates/index.ejs', data) + }) + + fastify.get('/propertyName', (req, reply) => { + reply.oldRenderSend('templates/index.ejs', data) + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + '/asyncPropertyName' + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(ejs.render(fs.readFileSync('./templates/index.ejs', 'utf8'), data), body.toString()) + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + '/propertyName' + }, (err2, response2, body2) => { + t.error(err2) + t.equal(response2.statusCode, 200) + t.equal(response2.headers['content-length'], '' + body.length) + t.equal(response2.headers['content-type'], 'text/html; charset=utf-8') + t.equal(ejs.render(fs.readFileSync('./templates/index.ejs', 'utf8'), data), body2.toString()) + fastify.close() + }) + }) + }) +}) + +test('reply.viewAsync with ejs engine and conflicting propertyName/asyncPropertyName', t => { + t.plan(1) + const fastify = Fastify() + const ejs = require('ejs') + + fastify.register(require('../index'), { + engine: { + ejs + }, + propertyName: 'render', + asyncPropertyName: 'render' + }) + + fastify.listen({ port: 0 }, err => { + t.ok(err instanceof Error) + }) +}) diff --git a/test/test-handlebars.js b/test/test-handlebars.js index d4e5999..6068926 100644 --- a/test/test-handlebars.js +++ b/test/test-handlebars.js @@ -1119,3 +1119,41 @@ test('fastify.view with handlebars engine and both layout', t => { }) }) }) + +test('reply.viewAsync for handlebars engine without defaultContext but with reply.locals and data-parameter, with async fastify hooks', t => { + t.plan(6) + const fastify = Fastify() + const handlebars = require('handlebars') + const localsData = { text: 'text from locals' } + const data = { text: 'text' } + + fastify.register(require('../index'), { + engine: { + handlebars + } + }) + + fastify.addHook('preHandler', async function (request, reply) { + reply.locals = localsData + }) + + fastify.get('/', async (req, reply) => { + return reply.viewAsync('./templates/index.html', data) + }) + + fastify.listen({ port: 0 }, err => { + t.error(err) + + sget({ + method: 'GET', + url: 'http://localhost:' + fastify.server.address().port + }, (err, response, body) => { + t.error(err) + t.equal(response.statusCode, 200) + t.equal(response.headers['content-length'], '' + body.length) + t.equal(response.headers['content-type'], 'text/html; charset=utf-8') + t.equal(handlebars.compile(fs.readFileSync('./templates/index.html', 'utf8'))(data), body.toString()) + fastify.close() + }) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index a211466..12a6bb1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -9,6 +9,8 @@ declare module "fastify" { interface FastifyReply { view