Skip to content

Commit

Permalink
feat: simplified html stream generation
Browse files Browse the repository at this point in the history
  • Loading branch information
arthurfiorette committed Mar 10, 2024
1 parent bf1521f commit 9b324af
Show file tree
Hide file tree
Showing 21 changed files with 238 additions and 724 deletions.
6 changes: 6 additions & 0 deletions .changeset/calm-games-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@kitajs/fastify-html-plugin': patch
'@kitajs/html': patch
---

Simplified html stream generation
115 changes: 20 additions & 95 deletions packages/fastify-html-plugin/index.js
Original file line number Diff line number Diff line change
@@ -1,132 +1,57 @@
/// <reference path="./types/index.d.ts" />

const fp = require('fastify-plugin');
const { pipeHtml } = require('@kitajs/html/suspense');
const { prependDoctype } = require('./lib/prepend-doctype');
const { isHtml } = require('./lib/is-html');
const { CONTENT_TYPE_VALUE } = require('./lib/constants');
const { Readable } = require('stream');
const { isTagHtml } = require('./lib/is-tag-html');

function noop() {}
// loads SUSPENSE_ROOT
require('@kitajs/html/suspense');

/**
* @type {import('fastify').FastifyPluginCallback<
* import('./types').FastifyKitaHtmlOptions
* >}
*/
function fastifyKitaHtml(fastify, opts, next) {
// Enables suspense if it's not enabled yet
SUSPENSE_ROOT.enabled ||= true;

// Good defaults
opts.autoDetect ??= true;
opts.autoDoctype ??= true;
opts.contentType ??= CONTENT_TYPE_VALUE;
opts.isHtml ??= isHtml;

// The normal .html handler is much simpler than the streamHtml one
fastify.decorateReply('html', html);
fastify.decorateReply('setupHtmlStream', setupHtmlStream);
fastify.decorateRequest('replyHtmlStream', null);

// As JSX is evaluated from the inside out, renderToStream() method requires
// a function to be able to execute some code before the JSX calls gets to
// render, it can be avoided by simply executing the code in the
// streamHtml getter method.
fastify.decorateReply('streamHtml', {
getter() {
this.setupHtmlStream();
return streamHtml;
}
});

if (opts.autoDetect) {
// The onSend hook is only used by autoDetect, so we can
// skip adding it if it's not enabled.
fastify.addHook('onSend', onSend);
}

return next();

/** @type {import('fastify').FastifyReply['setupHtmlStream']} */
function setupHtmlStream() {
SUSPENSE_ROOT.requests.set(this.request.id, {
// Creating a Readable is better than hijacking the reply stream
// https://lirantal.com/blog/avoid-fastify-reply-raw-and-reply-hijack-despite-being-a-powerful-http-streams-tool
stream: new Readable({ read: noop }),
running: 0,
sent: false
});

return this;
}

/** @type {import('fastify').FastifyReply['html']} */
function html(htmlStr) {
if (htmlStr instanceof Promise) {
// Handles possibility of html being a promise
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));
}

this.header('content-length', Buffer.byteLength(htmlStr));
this.header('content-type', opts.contentType);

if (opts.autoDoctype) {
htmlStr = prependDoctype(htmlStr);
// prepends doctype if the html is a full html document
if (opts.autoDoctype && isTagHtml(htmlStr)) {
htmlStr = '<!doctype html>' + htmlStr;
}

return this.send(htmlStr);
}
this.header('content-type', 'text/html; charset=utf-8');

/** @type {import('fastify').FastifyReply['streamHtml']} */
function streamHtml(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
this.header('content-type', opts.contentType);

if (opts.autoDoctype) {
if (htmlStr instanceof Promise) {
// Handles possibility of html being a promise
htmlStr = htmlStr.then(prependDoctype);
} else {
htmlStr = prependDoctype(htmlStr);
}
}

// When the .streamHtml is called, the fastify decorator's getter method
// already created the request data at the SUSPENSE_ROOT for us, so we
// can simply pipe the first html wave to the reply stream.
// If no suspense component was used, this will not be defined.
const requestData = SUSPENSE_ROOT.requests.get(this.request.id);

/* c8 ignore next 5 */
if (!requestData) {
throw new Error(
'The request data was not found, this is a bug in the fastify-html-plugin'
return (
this
// Nothing needs to be sent later, content length is known
.header('content-length', Buffer.byteLength(htmlStr))
.send(htmlStr)
);
}

// Pipes the HTML to the stream, this promise never
// throws, so we don't need to handle it.
pipeHtml(htmlStr, requestData.stream, this.request.id);

return this.send(requestData.stream);
}
requestData.stream.push(htmlStr);

/** @type {import('fastify').onSendHookHandler} */
function onSend(_request, reply, payload, done) {
if (opts.isHtml(payload)) {
// Streamed html should not enter here, because it's not a string,
// and already was handled by the streamHtml method.
reply.header('content-type', opts.contentType);

if (opts.autoDoctype) {
// Payload will never be a promise here, because the content was already
// serialized.
payload = prependDoctype(payload);
}
}
// 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 done(null, payload);
return this.send(requestData.stream);
}
}

Expand Down
14 changes: 0 additions & 14 deletions packages/fastify-html-plugin/lib/constants.js

This file was deleted.

26 changes: 0 additions & 26 deletions packages/fastify-html-plugin/lib/is-html.js

This file was deleted.

6 changes: 2 additions & 4 deletions packages/fastify-html-plugin/lib/is-tag-html.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
const { HTML_TAG_LENGTH, HTML_TAG } = require('./constants');

/**
* Returns true if the string starts with `<html`, **ignores whitespace and casing**.
*
Expand All @@ -12,8 +10,8 @@ module.exports.isTagHtml = function isTagHtml(value) {
// remove whitespace from the start of the string
.trimStart()
// get the first 5 characters
.slice(0, HTML_TAG_LENGTH)
.slice(0, 5)
// compare to `<html`
.toLowerCase() === HTML_TAG
.toLowerCase() === '<html'
);
};
13 changes: 0 additions & 13 deletions packages/fastify-html-plugin/lib/prepend-doctype.js

This file was deleted.

2 changes: 1 addition & 1 deletion packages/fastify-html-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kitajs/fastify-html-plugin",
"version": "1.0.0",
"version": "2.0.0",
"description": "A Fastify plugin to add support for @kitajs/html",
"homepage": "https://github.com/kitajs/html/tree/master/packages/fastify-html-plugin#readme",
"bugs": "https://github.com/kitajs/html/issues",
Expand Down
46 changes: 46 additions & 0 deletions packages/fastify-html-plugin/test/auto-detect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import fastify from 'fastify';
import assert from 'node:assert';
import test from 'node:test';
import { fastifyKitaHtml } from '../';

test('opts.autoDoctype', async (t) => {
await using app = fastify();
app.register(fastifyKitaHtml);

app.get('/default', () => <div>Not a html root element</div>);
app.get('/default/root', () => <html lang="en" />);
app.get('/html', (_, res) => res.html(<div>Not a html root element</div>));
app.get('/html/root', (_, res) => res.html(<html lang="en" />));

await t.test('Default', async () => {
const res = await app.inject({ method: 'GET', url: '/default' });

assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8');
assert.strictEqual(res.body, '<div>Not a html root element</div>');
});

await t.test('Default root', async () => {
const res = await app.inject({ method: 'GET', url: '/default/root' });

assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8');
assert.strictEqual(res.body, '<html lang="en"></html>');
});

await t.test('Html ', async () => {
const res = await app.inject({ method: 'GET', url: '/html' });

assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8');
assert.strictEqual(res.body, '<div>Not a html root element</div>');
});

await t.test('Html root', async () => {
const res = await app.inject({ method: 'GET', url: '/html/root' });

assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8');
assert.strictEqual(res.body, '<!doctype html><html lang="en"></html>');
});
});
74 changes: 0 additions & 74 deletions packages/fastify-html-plugin/test/auto-detect.tsx

This file was deleted.

Loading

0 comments on commit 9b324af

Please sign in to comment.