@popeindustries/lit-html-server

6.1.0 • Public • Published

NPM Version

@popeindustries/lit-html-server

Efficiently render streaming lit-html templates on the server (or in a ServiceWorker!).

Features

  • 6-7x faster than @lit-labs/ssr
  • render full HTML pages (not just <body>)
  • stream responses in Node.js and ServiceWorker, with first-class Promise and AsyncIterator support
  • render optional hydration metadata with hydratable directive
  • render web components with light or shadow DOM
  • default web component rendering with element.innerHTML and element.render() support
  • customisable web component rendering with ElementRenderer
  • compatible with lit-html/directives/*

Usage

Install with npm/yarn/pnpm:

$ npm install --save @popeindustries/lit-html-server

...write your lit-html template:

import { html } from '@popeindustries/lit-html-server';
// Most lit-html directives are compatible...
import { classMap } from 'lit-html/directives/class-map.js';
// ...except for the async ones ('async-append', 'async-replace', and 'until')
import { until } from '@popeindustries/lit-html-server/directives/until.js';

function Layout(data) {
  return html`
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>${data.title}</title>
      </head>
      <body>
        ${until(renderBody(data.api))}
      </body>
    </html>
  `;
}

async function renderBody(api) {
  // Some Promise-based request method
  const data = await fetchRemoteData(api);

  return html`
    <h1>${data.title}</h1>
    <my-el ?enabled="${data.hasWidget}"></my-el>
    <p class="${classMap({ negative: data.invertedText })}">${data.text}</p>
  `;
}

...and render (plain HTTP server example, though similar for Express/Fastify/etc):

import http from 'node:http';
import { renderToNodeStream } from '@popeindustries/lit-html-server';

http.createServer((request, response) => {
  const data = { title: 'Home', api: '/api/home' };
  response.writeHead(200);
  // Returns a Node.js Readable stream which can be piped to "response"
  renderToNodeStream(Layout(data)).pipe(response);
});

Hydration

Server rendered HTML may be converted to live lit-html templates with the help of inline metadata. This process of reusing static HTML to seamlessly bootstrap dynamic templates is referred to as hydration.

lit-html-server does not output hydration metadata by default, but instead requires that a sub-tree is designated as hydratable via the hydratable directive:

import { hydratable } from '@popeindustries/lit-html-server/directives/hydratable.js';

function Layout(data) {
  return html`
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>${data.title}</title>
      </head>
      <body>
        <h1>Some ${data.title}</h1>
        ${hydratable(renderMenu(data.api))}
        <p>
          Some paragraph of text to show that multiple<br />
          hydration sub-trees can exist in the same container.
        </p>
        ${hydratable(renderPage(data.api))}
        <footer>Some footer</footer>
      </body>
    </html>
  `;
}

...which generates output similar to:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
  </head>
  <body>
    <h1>Some Title</h1>
    <!--lit qKZ2lAadfCg=-->
    <nav negative>
      <!--lit-attr 1--><!--lit-child--><!--lit-child zRvOSEJDeXc=--><button><!--lit-child-->one<!--/lit-child--></button
      ><!--/lit-child--><!--lit-child zRvOSEJDeXc=--><button><!--lit-child-->two<!--/lit-child--></button
      ><!--/lit-child--><!--lit-child zRvOSEJDeXc=--><button><!--lit-child-->three<!--/lit-child--></button
      ><!--/lit-child--><!--/lit-child-->
    </nav>
    <!--/lit-->
    <p>
      Some paragraph of text to show that multiple<br />
      hydration sub-trees can exist in the same container.
    </p>
    <!--lit 83OJYYYBUzs=-->
    <main>This is the main page content.</main>
    <!--/lit-->
    <footer>Some footer</footer>
  </body>
</html>

In order to efficiently reuse templates on the client (renderMenu and renderPage in the example above), they should be hydrated and rendered with the help of @popeindustries/lit-html.

Web Components

The rendering of web component content is largely handled by custom ElementRenderer instances that adhere to the following interface:

declare class ElementRenderer {
  /**
   * Should return true when given custom element class and/or tag name
   * should be handled by this renderer.
   */
  static matchesClass(ceClass: typeof HTMLElement, tagName: string): boolean;
  /**
   * The custom element instance
   */
  readonly element: HTMLElement;
  /**
   * The custom element tag name
   */
  readonly tagName: string;
  /**
   * The element's observed attributes
   */
  readonly observedAttributes: Array<string>;
  /**
   * Constructor
   */
  constructor(tagName: string);
  /**
   * Function called when element is to be rendered
   */
  connectedCallback(): void;
  /**
   * Function called when observed element attribute value has changed
   */
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
  /**
   * Update element property value
   */
  setProperty(name: string, value: unknown): void;
  /**
   * Update element attribute value
   */
  setAttribute(name: string, value: string): void;
  /**
   * Render element attributes as string
   */
  renderAttributes(): string;
  /**
   * Render element styles as string for applying to shadow DOM
   */
  renderStyles(): string;
  /**
   * Render element content
   */
  render(): TemplateResult | string | null | undefined;
}

Custom ElementRenderer instances should subclass the default renderer, and be passed along to the render function:

import { renderToNodeStream } from '@popeindustries/lit-html-server';
import { ElementRenderer } from '@popeindustries/lit-html-server/element-renderer.js';

class MyElementRenderer extends ElementRenderer {
  static matchesClass(ceClass, tagName) {
    return '__myElementIdentifier__' in ceClass;
  }

  render() {
    return this.element.myElementRenderFn();
  }
}

const stream = renderToNodeStream(Layout(data), {
  elementRenderers: [MyElementRenderer],
});

Note the default ElementRenderer will render innerHTML strings, or content returned by this.element.render(), in either light or shadow DOM.

See @popeindustries/lit-element for LitElement support.

Shadow DOM

If attachShadow() has been called by an element during construction/connection, lit-html-server will render the custom element content in a declarative Shadow DOM:

<!--lit Ph5bNbG/om0=-->
<my-el>
  <!--lit-attr 0-->
  <template shadowroot="open"> <!--lit iW9ZALRtWQA=-->text<!--/lit--> </template>
</my-el>
<!--/lit-->

Disabling server render

For web components that will only be rendered on the client, add the render:client attribute to disable server-rendering for that component:

html`<my-el render:client><span slot="my-text">some text</span></my-el>`;

Lazy (partial/deferred) hydration

When rendering web components, lit-html-server adds hydrate:defer attributes to nested custom elements. This provides a mechanism to control and defer the hydration order of components that may be dependant on data passed from a parent. See lazy-hydration-mixin for more on lazy hydration.

DOM polyfills

In order to support importing and evaluating custom element code in Node, minimal DOM polyfills are attached to the Node global when @popeindustries/lit-html-server is imported. See dom-shim.js for details.

Directives

Most of the built-in lit-html/directives/* already support server-rendering, and work as expected in lit-html-server, the exception being those directives that are asynchronous. lit-html-server supports the rendering of Promise and AsyncInterator as first-class primitives, so versions of async-append.js, async-replace.js, and until.js should be imported from @popeindustries/lit-html-server/directives.

Benchmarks

Benchmarks for rendering a complex template in lit-html-server vs. @lit-labs/ssr:

# @popeindustries/lit-html-server
$ node ./benchmark/perf.js
┌─────────┬────────┬────────┬────────┬────────┬───────────┬──────────┬────────┐
│ Stat    │ 2.5%   │ 50%    │ 97.5%  │ 99%    │ Avg       │ Stdev    │ Max    │
├─────────┼────────┼────────┼────────┼────────┼───────────┼──────────┼────────┤
│ Latency │ 381 ms │ 541 ms │ 553 ms │ 588 ms │ 509.52 ms │ 66.97 ms │ 761 ms │
└─────────┴────────┴────────┴────────┴────────┴───────────┴──────────┴────────┘
┌───────────┬────────┬────────┬────────┬────────┬─────────┬─────────┬────────┐
│ Stat      │ 1%     │ 2.5%   │ 50%    │ 97.5%  │ Avg     │ Stdev   │ Min    │
├───────────┼────────┼────────┼────────┼────────┼─────────┼─────────┼────────┤
│ Req/Sec   │ 7939   │ 7939   │ 9207   │ 9327   │ 9092.55 │ 370.06  │ 7938   │
├───────────┼────────┼────────┼────────┼────────┼─────────┼─────────┼────────┤
│ Bytes/Sec │ 150 MB │ 150 MB │ 174 MB │ 175 MB │ 172 MB  │ 6.89 MB │ 150 MB │
└───────────┴────────┴────────┴────────┴────────┴─────────┴─────────┴────────┘
# @lit-labs/ssr
$ node ./benchmark/perf.js ssr
┌─────────┬────────┬─────────┬─────────┬─────────┬────────────┬────────────┬─────────┐
│ Stat    │ 2.5%   │ 50%     │ 97.5%   │ 99%     │ Avg        │ Stdev      │ Max     │
├─────────┼────────┼─────────┼─────────┼─────────┼────────────┼────────────┼─────────┤
│ Latency │ 633 ms │ 4605 ms │ 6353 ms │ 6588 ms │ 3987.46 ms │ 1641.11 ms │ 7517 ms │
└─────────┴────────┴─────────┴─────────┴─────────┴────────────┴────────────┴─────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬─────────┬────────┬─────────┐
│ Stat      │ 1%      │ 2.5%    │ 50%     │ 97.5%   │ Avg     │ Stdev  │ Min     │
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼────────┼─────────┤
│ Req/Sec   │ 975     │ 975     │ 1280    │ 1581    │ 1322.7  │ 165.19 │ 975     │
├───────────┼─────────┼─────────┼─────────┼─────────┼─────────┼────────┼─────────┤
│ Bytes/Sec │ 20.5 MB │ 20.5 MB │ 26.8 MB │ 33.8 MB │ 27.9 MB │ 3.6 MB │ 20.5 MB │
└───────────┴─────────┴─────────┴─────────┴─────────┴─────────┴────────┴─────────┘

(Results from local run on 2022 Macbook Air with Node@19.4.0)

API

RenderOptions

The following render methods accept an options object with the following properties:

  • elementRenderers?: Array<ElementRendererConstructor> - ElementRenderer subclasses for rendering of custom elements.

renderToNodeStream(value: unknown, options?: RenderOptions): Readable

Returns the value (generally the result of a template tagged by html) as a Node.js Readable stream of markup:

import { html, renderToNodeStream } from '@popeindustries/lit-html-server';

const name = 'Bob';
renderToNodeStream(html`<h1>Hello ${name}!</h1>`).pipe(response);

renderToWebStream(value: unknown, options?: RenderOptions): ReadableStream

Returns the value (generally the result of a template tagged by html) as a web ReadableStream stream of markup:

import { html, renderToWebStream } from '@popeindustries/lit-html-server';

self.addEventListener('fetch', (event) => {
  const name = 'Bob';
  const stream = renderToWebStream(html`<h1>Hello ${name}!</h1>`);
  const response = new Response(stream, {
    headers: {
      'content-type': 'text/html',
    },
  });

  event.respondWith(response);
});

Note: due to the slight differences when running in Node or the browser, a separate version for running in a browser environment is exported as @popeindustries/lit-html-server/lit-html-service-worker.js. For those dev servers/bundlers that support conditional package.json#exports, exports are provided to enable importing directly from @popeindustries/lit-html-server.

renderToString(value: unknown, options?: RenderOptions): Promise<string>

Returns the value (generally the result of a template tagged by html) as a Promise which resolves to a string of markup:

import { html, renderToString } from '@popeindustries/lit-html-server';

const name = 'Bob';
const markup = await renderToString(html` <h1>Hello ${name}!</h1> `);
response.end(markup);

renderToBuffer(value: unknown, options?: RenderOptions): Promise<Buffer>

Returns the value (generally the result of a template tagged by html) as a Promise which resolves to a Buffer of markup:

import { html, renderToBuffer } from '@popeindustries/lit-html-server';

const name = 'Bob';
const markup = await renderToBuffer(html` <h1>Hello ${name}!</h1> `);
response.end(markup);

Package Sidebar

Install

npm i @popeindustries/lit-html-server

Weekly Downloads

864

Version

6.1.0

License

MIT

Unpacked Size

115 kB

Total Files

19

Last publish

Collaborators

  • simenbrekken
  • popeindustries