/ocean

Web component server-side rendering

Primary LanguageJavaScriptBSD 2-Clause "Simplified" LicenseBSD-2-Clause

🌊 Ocean

Web component HTML rendering that includes:

  • Rendering to Declarative Shadow DOM, requiring no JavaScript in the client.
  • Automatic inclusion of the Declarative Shadow DOM polyfill for browsers without support.
  • Streaming HTML responses.
  • Compatibility with the most popular web component libraries (see a compatibility list below).
  • Lazy partial hydration via special attributes: hydrate on page load, CPU idle, element visibility, or media queries. Or create your own hydrator.

Table of Contents

Overview

An ocean is an environment for rendering web component code. It provides an html function that looks like the ones you're used to from libraries like uhtml and Lit. Instead of creating reactive DOM in the client like those libraries, Ocean's html returns an async iterator that will stream out HTML strings.

Ocean is somewhat low-level and is meant to be used with a higher-level framework. Typical usage looks like this:

import 'https://cdn.spooky.click/ocean/1.3.1/shim.js?global';
import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';

const { HTMLElement, customElements, document } = globalThis;

class AppRoot extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  connectedCallback() {
    let div = document.createElement('div');
    div.textContent = `This is an app!`;
    this.shadowRoot.append(div);
  }
}

customElements.define('app-root', AppRoot);

const { html } = new Ocean({
  document,
  polyfillURL: '/webcomponents/declarative-shadow-dom.js'
});

let iterator = html`
  <!doctype html>
  <html lang="en">
  <title>My app</title>

  <app-root></app-root>
`;

let code = '';
for await(let chunk of iterator) {
  code += chunk;
}
console.log(chunk); // HTML string

The above will generate the following HTML:

<!doctype html>
<html lang="en">
<title>My app</title>

<script type="module">const o=(new DOMParser).parseFromString('<p><template shadowroot="open"></template></p>',"text/html",{includeShadowRoots:!0}).querySelector("p");o&&o.shadowRoot||async function(){const{hydrateShadowRoots:o}=await import("/webcomponents/declarative-shadow-dom.js");o(document.body)}()</script>
<app-root>
  <template shadowroot="open">
    <div>This is an app!</div>
  </template>
</app-root>

Modules

Ocean comes with its main module and a DOM shim for compatible with custom element code.

Main module

The main module for Ocean is available in two forms: bundled and unbundled.

  • If you are using Ocean in a browser context, such as a service worker, use the bundled version.
  • If you are using Ocean in Deno, use the unbundled version.

Unbundled

import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';

Bundled

import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.bundle.js';

DOM shim

Ocean's DOM shim is backed by linkedom, a fast DOM layer. The shim also bridges compatibility with popular web component libraries.

It's important to import the DOM shim as one of the first imports in your app.

import 'https://cdn.spooky.click/ocean/1.3.1/shim.js?global';

Notice that this includes in the ?global query parameter. This makes the shim available on globals; you get document, customElements, and other commonly used global variables.

If you do not want to shim the global environment you can omit the ?global query parameter and instead get the globals yourself from the symbol Symbol.for('dom-shim.defaultView'). This is advanced usage.

import 'https://cdn.spooky.click/ocean/1.3.1/shim.js';

const root = globalThis[Symbol.for('dom-shim.defaultView')];
const { HTMLElement, customElements, document } = root;

Hydration

Partial hydration is the practice of only hydrating (via running client JavaScript) components that are needed for interactivity. Ocean does not automatically add scripts for components by default. However Ocean does support both full and partial hydration. This means you can omit the component script tags from your HTML and Ocean will automatically add them for you.

In order to add script tags you have to provide Ocean a map of tag names to URLs to load. You do this through the elements Map that is returned from the constructor.

let { html, elements } = new Ocean({
  document
});

elements.set('app-sidebar', '/elements/app-sidebar.js');

Note: Ocean only adds script tags for elements that are server rendered. If you are not server rendering an element you will need to add the appropriate script tags yourself.

Full hydration

Full hydration means added script tags to the <head> for any components that are server rendered. You can enable full hydration by passing this in the constructor:

let { html, elements } = new Ocean({
  document,
  hydration: 'full'
});

elements.set('app-sidebar', '/elements/app-sidebar.js');

customElements.define('app-sidebar', class extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  connectedCallback() {
    let div = document.createElement('div');
    div.textContent = `My sidebar...`;
    this.shadowRoot.append(div);
  }
});

Then when you render this element, it will include the script tags:

let iterator = html`
  <!doctype html>
  <html lang="en">
  <title>My app</title>

  <app-sidebar></app-sidebar>
`;

let out = '';
for(let chunk of iterator) {
  out += chunk;
}

Will produce this HTML:

<!doctype html>
<html lang="en">
<title>My app</title>
<script type="module" src="/elements/app-sidebar.js"></script>

<app-sidebar>
  <template shadowroot="open">
    <div>My sidebar...</div>
  </template>
</app-sidebar>

Partial hydration

By default Ocean uses partial hydration. In partial hydration script tags are only added when you explicitly tell Ocean to hydration an element. This means that by default elements will be rendered to HTML only, and never iteractive on the client.

This allows you to use the web component libraries you love both to produce static HTML and for interactive content.

To declare an element to be hydrated, use the ocean-hydrate attribute on any element. The value should be one of:

  • load: Hydrate when the page loads. Ocean will add a <script type="module"> tag for the element's script.
  • idle: Hydrate when the CPU becomes idle. Ocean will add an inline script that waits for requestIdleCallback and then loads the element's script.
  • media: Hydrates on a matching media query. This allows you to have some elements which only hydrate for certain screen sizes. Use the ocean-query attribute to specify the media query.
  • visible: Hydrate when the element becomes visible. This is useful for elements which are shown further down the page. Ocean will add an inline script that uses Intersection Observer to determine when the element is visible and then loads the script.

Using one of these hydrators looks like:

let iterator = html`
  <!doctype html>
  <html lang="en">
  <title>My site</title>

  <app-sidebar ocean-hydrate="idle"></app-sidebar>
`;

Hydrator options

You can specify which hydrators you want to use by providing the hydrators option to Ocean. Each of the default hydrators are included by default, but can also be imported.

import {
  HydrateIdle,
  HydrateLoad,
  HydrateMedia,
  HydrateVisible,
  Ocean
} from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';
Load

To specify to hydrate on load, pass load into the ocean-hydrate attr:

let { html } = new Ocean({ document });

let iterator = html`
  <!doctype html>
  <html lang="en">
  <title>My site</title>

  <app-sidebar ocean-hydrate="load"></app-sidebar>
`;

HydrateLoad does not take any options because it only adds a script tag to the head. You can create an instance by calling new on it:

import { HydrateLoad, Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';

let { html } = new Ocean({
  document,
  hydrators: [
    new HydrateLoad()
  ]
});
Idle

To specify to hydrate on idle, pass idle into the ocean-hydrate attr:

let { html } = new Ocean({ document });

let iterator = html`
  <!doctype html>
  <html lang="en">
  <title>My site</title>

  <app-sidebar ocean-hydrate="idle"></app-sidebar>
`;

HydrateIdle uses a custom element to perform hydration when the CPU is idle. By default that custom element name is ocean-hydrate-idle. You can specify a different custom element name by passing it into the constructor.

import { HydrateIdle, Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';

let { html } = new Ocean({
  document,
  hydrators: [
    new HydrateIdle('my-app-hydrate-idle')
  ]
});
Media

To hydrate on a media query, pass media into the ocean-hydrate attr, and also provide a ocean-query attr with the media query to use:

let { html } = new Ocean({ document });

let iterator = html`
  <!doctype html>
  <html lang="en">
  <title>My site</title>

  <app-sidebar ocean-hydrate="media" ocean-query="(max-width: 700px)"></app-sidebar>
`

HydrateMedia uses the custom element ocean-hydrate-media to hydrate your custom element. You can customize this, and also the attribute used for the query by passing those arguments into the constructor:

import { HydrateMedia, Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';

let { html } = new Ocean({
  document,
  hydrators: [
    new HydrateMedia('my-app-hydrate-media', 'app-query')
  ]
});

let iterator = html`
  <!doctype html>
  <html lang="en">
  <title>My site</title>

  <app-sidebar ocean-hydrate="media" app-query="(max-width: 700px)"></app-sidebar>
`;
Visible

To specify to hydrate on element visibility, pass visible into the ocean-hydrate attr:

let { html } = new Ocean({ document });

let iterator = html`
  <!doctype html>
  <html lang="en">
  <title>My site</title>

  <app-sidebar ocean-hydrate="visible"></app-sidebar>
`;

HydrateVisible uses the custom element ocean-hydrate-visible to track when your element is visible. You can customize this custom element tag name by passing in something else into the constructor:

import { HydrateVisible, Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';

let { html } = new Ocean({
  document,
  hydrators: [
    new HydrateVisible('my-app-hydrate-visible')
  ]
});

Custom hydrator

A hydrator is an object that specifies how to hydrate the element. You can create a custom hydrator and pass it to the hydrators option.

The following is a hydrator that hydrates whenever the element is clicked.

const clickHydrator = {
  condition: 'click',
  tagName: 'my-click-hydrator',
  renderMultiple: true,
  script() {
    return /* js */ `customElements.define('${this.tagName}', class extends HTMLElement {
  connectedCallback() {
    let el = this.previousElementSibling;
    let src = this.getAttribute('src');
    el.addEventListener('click', () => import(src), { once: true });
  }
})`;
  }
};

let { html } = new Ocean({
  document,
  hydrators: [
    clickHydrator
  ]
})

Which you would use like so:

let iterator = html`
  <!doctype html>
  <html lang="en">
  <title>My site</title>

  <app-sidebar ocean-hydrate="click"></app-sidebar>
`;

The properties of a hydrator are (all required):

  • condition: This is the value used with ocean-hydrate to trigger the hydrator to be used.
  • tagName: Hydrators are implemented as custom elements. The tagName is the custom element tag name.
  • renderMultiple: This says that the custom element should be rendered for each element that uses the hydrator. Use false when hydrating is done without regard for the element. For example idle is false because it always just waits for CPU idle, so this only needs to be done once.
  • script(): A function which returns the custom element definition.

The following are optional properties:

  • mutate(customElement, node): Gives you a change to modify the hydration custom element being rendered, for example to add information needed to perform hydration. HydrateMedia uses this method to add the query to the custom element.

Relative links

When performing hydration or adding the declarative shadow DOM polyfill, Ocean adds links that you provide it. You can provide full URLs or pathnames like /js/dsd-polyfill.js. If you'd like for these links to be relative, you can use the relativeTo function to create an html that will produce relative links. Here's how you might use it in a service worker context:

import 'https://cdn.spooky.click/ocean/1.3.1/shim.js?global';
import { Ocean } from 'https://cdn.spooky.click/ocean/1.3.1/mod.js';

let { relativeTo } = new Ocean({
  document,
  polyfillURL: '/js/dsd-polyfill.js'
})

addEventListener('fetch', event => {
  let html = relativeTo(event.request.url);
  let iter = html`
    <!doctype html>
    <html lang="en">
    <!-- ... -->
  `;
});

The script tags added for the polyfill and for any element hydration will be relative to the event's URL.

Plugins

Ocean parses HTML into a DOM tree. Using plugins you can mutate the tree before it gets turned back into strings, allowing you to implement advanced behavior like syntax highlighting.

For the most part custom elements should be the way you customize HTML rendering; plugins are here for cases where you need to modify built-in elements.

The interface for a plugin is a function that returns an object with a handle method. The function is called during Ocean's internal optimization step:

class MyHighlighter {
  handle(node, head) {
    // Mutate this node, add anything to the head that you need.
  }

  static createInstance() {
    return new MyHighlighter();
  }
}

let ocean = new Ocean({
  document,
  plugins: [MyHighlighter.createInstance]
});

Compatibility

Ocean is tested against popular web component libraries. These tests are not all inclusive, test contributions are very much welcome.

Library Compatible Notes
Vanilla ✔
Lit ✔
Stencil ✔
Haunted ✔
Atomico ✔
uce ✔
Preact ✔
petite-vue ✔
Wafer ✔
FAST ✖ Heavily relies on DOM internals.
Lightning Web Components ✖ I can't figure out how to export an LWC, if you can help see #11