htms-server
v0.9.0
Published
HTMS 💨 Stream Async HTML, Stay SEO-Friendly
Readme
htms-server
Small CLI to quickly test HTML streaming with htms-js without writing code.
Try the live demo
- https://htms.skarab42.dev
Install (global)
Use your preferred package manager to install the CLI globally:
pnpm add -g htms-server
# or
npm i -g htms-server
# or
yarn global add htms-server
# or
bun add -g htms-serverThis will expose the htms-server command.
Without global installation
You can also run it directly without installing it globally:
npx htms-server start
# or
pnpm dlx htms-server start
# or
yarn dlx htms-server start
# or
bunx htms-server startThis will run the htms-server start command.
Prerequisite
Before starting the server, you need at least one HTML file and a module that exports functions used by HTMS placeholders. These functions will be called to progressively fill in the HTML while it streams.
Example setup:
<!-- ./public/index.html -->
<!doctype html>
<html lang="en">
<body>
<h1>News feed</h1>
<div data-htms="loadNews">Loading news…</div>
<h1>User profile</h1>
<div data-htms="loadProfile">Loading profile…</div>
</body>
</html>// ./public/index.js
export async function loadNews() {
await new Promise((r) => setTimeout(r, 100));
return `<ul><li>Breaking story</li><li>Another headline</li></ul>`;
}
export async function loadProfile() {
await new Promise((r) => setTimeout(r, 200));
return `<div class="profile">Hello, user!</div>`;
}Start server
htms-server start [options]When you run the server, htms-js will scan the HTML for elements with data-htms attributes, then dynamically import the functions from the matching module (index.js) to resolve and stream the content.
Scoped modules
HTMS supports scoped modules, meaning tasks can resolve from different modules depending on context. You can nest modules and HTMS will pick the right scope for each placeholder.
<section data-htms-module="root-module.js">
<div data-htms="taskA">loading task A from 'root-module.js'...</div>
<div data-htms="taskA" data-htms-module="child-module.js">loading task A from 'child-module.js'...</div>
<div data-htms-module="child-module.js">
<div data-htms="taskA">loading task A from 'child-module.js'...</div>
<div data-htms="taskA" data-htms-module="root-module.js">loading task A from 'root-module.js'...</div>
</div>
<div data-htms="taskB">loading task B from 'root-module.js'...</div>
<div data-htms="taskB" data-htms-module="child-module.js">loading task B from 'child-module.js'...</div>
</section>This makes it easier to compose and reuse modules without conflicts.
Task value (data-htms-value)
data-htms-value passes one argument to the task.
When present, the value is parsed as JSON5 and given to the task as its first parameter.
If the attribute is omitted, the task receives undefined.
- Accepted:
undefined,null, booleans, numbers, strings, arrays, objects (JSON5: single quotes, unquoted keys, comments, trailing commas). - Not accepted: functions, arbitrary JS expressions.
- Need multiple pieces of data? Pack them into one object or array.
HTML examples
<!-- attribute omitted → value = undefined -->
<div data-htms="loadDefaults"></div>
<!-- primitive values -->
<div data-htms="loadDefaults" data-htms-value="null"></div>
<div data-htms="loadProfile" data-htms-value="true"></div>
<div data-htms="loadUser" data-htms-value="12345"></div>
<div data-htms="loadByName" data-htms-value="'john-doe'"></div>
<!-- object / array (JSON5) -->
<div data-htms="loadFeed" data-htms-value="{ theme: 'compact', limit: 10 }"></div>
<div data-htms="renderOffer" data-htms-value="[42, { theme: 'compact' }]"></div>Task signatures (TypeScript examples)
export async function loadDefaults(value: undefined | null) {}
export async function loadProfile(value: boolean) {}
export async function loadUser(value: number) {}
export async function loadByName(value: string) {}
export async function loadFeed(value: { theme: string; limit: number }) {
// value.theme === 'compact'
// value.limit === 10
}
export async function renderOffer(value: [number, { theme: string }]) {
const [offerId, options] = value;
// offerId === 42
// options.theme === 'compact'
}Tips
- Keep it serializable. Only data you could express in JSON5 should go here.
- Prefer objects when the meaning of fields matters:
{ id, page, sort }is clearer than[id, page, sort]. - Strings must be quoted. Use JSON5 single quotes in HTML to stay readable.
- Validate inside the task. Treat the value as untrusted input.
- One argument by design. If you need several inputs, bundle them:
(value)wherevalueis an object/array.
Commit behavior (data-htms-commit)
Controls how the streamed result is applied to the placeholder. Default: replace.
| Value | Effect | DOM equivalent |
| --------- | --------------------------------------------------- | ---------------------------- |
| replace | Replace the placeholder node (outer) | host.replaceWith(frag) |
| content | Replace the children of the placeholder (inner) | host.replaceChildren(frag) |
| append | Append result as last child | host.append(frag) |
| prepend | Insert result as first child | host.prepend(frag) |
| before | Insert result before the placeholder | host.before(frag) |
| after | Insert result after the placeholder | host.after(frag) |
HTML examples
Assuming the streamed content is: <div>Streamed</div>
<!-- replace (default): host node is replaced by the content -->
<div data-htms="getUser" data-htms-commit="replace">Loading…</div>
<!-- becomes -->
<div>Streamed</div>
<!-- content: keep the host, swap its children -->
<div data-htms="getUser" data-htms-commit="content">Loading…</div>
<!-- becomes -->
<div><div>Streamed</div></div>
<!-- append: add at the end of host -->
<section data-htms="getUser" data-htms-commit="append"><div>Existing</div></section>
<!-- becomes -->
<section>
<div>Existing</div>
<div>Streamed</div>
</section>
<!-- prepend: add at the beginning of host -->
<section data-htms="getUser" data-htms-commit="prepend"><div>Existing</div></section>
<!-- becomes -->
<section>
<div>Streamed</div>
<div>Existing</div>
</section>
<!-- before: insert before the host -->
<hr data-htms="getUser" data-htms-commit="before" />
<!-- becomes -->
<div>Streamed</div>
<hr />
<!-- after: insert after the host -->
<hr data-htms="getUser" data-htms-commit="after" />
<!-- becomes -->
<hr />
<div>Streamed</div>Notes
- With
append,prepend,before,after, the placeholder stays in the DOM. Remove or restyle it if needed once the chunk is committed. - With
content, you keep the container (useful for accessibility/live regions).
Accessibility (content mode)
When data-htms-commit="content" is used, HTMS automatically marks the placeholder as a polite live region while it is pending:
- Adds
role="status"andaria-busy="true"on the host before the first update. - On commit, flips
aria-busytofalseso screen readers announce the final content once.
This gives you accessible announcements out of the box, without extra markup. If you need a different behavior, switch to another commit mode or set your own ARIA attributes on the host.
Usage
htms-server start [options]Starts a local server that serves .html files and streams them through the HTMS pipeline.
Options
| Flag | Description | Default |
| --------------------- | ----------------------------- | ------------------------------------------------- |
| --host <host> | Host to bind | localhost |
| --port <port> | Port to listen on | 4200 |
| --root <path> | Root directory to serve | ./public |
| --environment <env> | production or development | production |
| --compression | Enable response compression | true |
| --cache-module | Enable module caching | false (true if undefined and development) |
| --logger | Enable logging | false (true if undefined and development) |
Examples
Serve the ./public folder with defaults:
htms-server startCustom port and root:
htms-server start --root ./examples --port 8080 --logger --environment developmentOpen the shown URL in your browser to see HTML streaming in action.
Notes
- This CLI is for quick local testing. For integration in a Fastify app, use fastify-htms.
- For how HTMS works (resolvers, placeholders, etc.), see htms-js.
Status
This is experimental. APIs may change.
We'd love developers to:
- Experiment in different contexts.
- Find limits: performance, DX, compatibility.
- Challenge assumptions and suggest alternatives.
- See if it fits your framework or stack.
Contribute
Help explore whether streaming HTML can be practical:
The only way to know where this works or breaks is to try it together.
License
MIT
