bennypowers/backlit

Server-render Lit web components from Drupal. No Node.js. No containers. Just vibes and NUL bytes.

Maintainers

Package info

github.com/bennypowers/backlit

Type:drupal-module

pkg:composer/bennypowers/backlit

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 7

Open Issues: 3

v0.1.0 2026-03-23 15:07 UTC

This package is auto-updated.

Last update: 2026-05-01 00:08:46 UTC


README

Backlit

Server-render Lit web components from Drupal. No Node.js. No containers. No HTTP sidecar. Just a single binary that speaks NUL bytes.

Backlit hooks into Drupal's response pipeline and renders every Lit web component with Declarative Shadow DOM. Users see styled, laid-out content on first paint -- before any JavaScript loads. Disable JS entirely and the components still render. That is the way of the Lit.

Quick start

composer require bennypowers/backlit
drush en backlit

That's it. Composer downloads the right binary for your platform. Drush enables the module. Every page response now gets its web components server-rendered.

If the binary download didn't run automatically:

cd web/modules/contrib/backlit
./scripts/download-binary.sh

How it works

Backlit ships a pre-compiled Go binary that embeds a WASM module. Inside that WASM module: QuickJS running @lit-labs/ssr. On startup, the binary bundles your component source files (JS or TS) with esbuild, evaluates the bundle inside QuickJS, and registers your custom elements.

When Drupal finishes rendering a page, Backlit's SsrResponseSubscriber intercepts the response, pipes the HTML through the binary's stdin, and reads Declarative Shadow DOM enhanced HTML from stdout. The binary uses a NUL-delimited read-loop protocol, so the WASM instance and your component definitions stay warm across renders.

First render:  ~350ms  (WASM cold start -- paid once per PHP-FPM worker)
Every render after:  ~0.32ms  (just pipe I/O)

The binary auto-detects your platform. Supported: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64. Yes, we support Windows. No, we haven't tested it. Godspeed.

Adding your components

Drop JS or TS source files into one of these locations. Backlit aggregates component files from all of them:

  1. $settings['backlit']['components_dir'] in settings.php
  2. Your active theme's components/ directory -- e.g., themes/custom/my_theme/components/
  3. Any custom module's js/ directory -- e.g., modules/custom/my_components/js/

The binary bundles your source files with esbuild at startup, resolving imports from node_modules. Declaration files (.d.ts) and test files (.test.ts, .test.js) are excluded automatically.

What the source looks like

Standard LitElement with standard imports:

import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('my-card')
class MyCard extends LitElement {
  @property() heading = '';

  static styles = css`
    :host { display: block; border: 1px solid #ccc; border-radius: 8px; }
    #header { padding: 16px; font-weight: 600; }
    #body { padding: 16px; }
  `;

  render() {
    return html`
      <div id="header">${this.heading}</div>
      <div id="body"><slot></slot></div>
    `;
  }
}

Then use it in any Drupal content (Full HTML format):

<my-card heading="Dashboard">
  <p>All systems operational.</p>
</my-card>

Components stay registered across renders -- the binary bundles and evaluates your source once, then keeps the definitions warm.

Pre-bundled mode (advanced)

If you prefer to bundle components yourself, you can pass a pre-built JS bundle via $settings['backlit']['bundle'] in settings.php. The binary will skip esbuild and evaluate the bundle directly.

Performance

Metric Value
Cold start ~350ms (once per PHP-FPM worker)
Warm render ~0.32ms
Binary size ~9 MB (statically linked, no dependencies)
Memory ~20 MB per WASM instance
Dependencies Zero. The binary is the dependency.

For comparison, the previous approach required a Node.js container, HTTP round-trips, and ~50ms per render. The drop has moved.

Requirements

  • Drupal 10 or 11
  • PHP 8.1+
  • A server that can run a binary (so, any server)
  • node_modules with lit installed (esbuild resolves imports at startup)
  • No PHP extensions, no PECL, no FFI, no containers

Graceful degradation

If the binary isn't available or fails to start, Backlit returns the original HTML unchanged. Your components will still work client-side once their JavaScript loads -- they just won't have the instant first paint from DSD. This means you can develop locally without the binary and deploy with it.

Architecture

Browser request
       |
   Drupal renders page (Twig, etc.)
       |
   KernelEvents::RESPONSE
       |
   SsrResponseSubscriber
       |
   LitSsrRenderer::render()
       |
   proc_open() -> lit-ssr binary (Go + wazero + QuickJS + @lit-labs/ssr)
       |
   stdin: HTML\0  ->  stdout: HTML-with-DSD\0
       |
   Response sent to browser with Declarative Shadow DOM
       |
   User sees styled content immediately
   (JavaScript loads later, hydrates if needed)

FAQ

Does this work with any web component framework? Only Lit. The SSR engine is @lit-labs/ssr, which understands Lit's template system. Vanilla custom elements or other frameworks would need their own SSR implementation.

What about caching? Backlit operates on every response. If you have Drupal's page cache enabled, the rendered HTML (with DSD) gets cached, so subsequent requests skip the binary entirely. This is the recommended setup.

What about BigPipe? Backlit runs in a KernelEvents::RESPONSE subscriber, which processes the initial HTML response before it is sent to the browser. BigPipe replaces placeholder markup with real content after that initial response, streaming replacement <script> tags that swap in the final HTML on the client. Web components inside BigPipe-delivered placeholders will not be server-rendered by Backlit, since those fragments arrive after the response subscriber has already run. Components will still render client-side once their JavaScript loads, but they will not benefit from Declarative Shadow DOM on first paint. If your site relies heavily on BigPipe for lazy block rendering and those blocks contain web components, be aware of this limitation.

Can I use this in production? The binary is statically linked with no runtime dependencies. The protocol is simple (NUL-delimited pipes). The failure mode is graceful (returns original HTML). So... probably? But this is still early days. File issues, send PRs, report back.

Why "Backlit"? Because your Lit components are lit from behind -- server-rendered before the browser even sees them. Also because naming things is hard and this one was available.

Links

License

MIT