htms-js
v0.9.0
Published
HTMS 💨 Stream Async HTML, Stay SEO-Friendly
Readme
htms-js 💨 Stream Async HTML, Stay SEO-Friendly
Send HTML that renders instantly, then fills itself in as async tasks complete. One response. No hydration. No empty shells.
htms-js is an early-stage project: a proposal to progressively render HTML with async functions, while staying SEO-friendly and lightweight. It's not meant as the new default, but as an alternative that can fit into many stacks or frameworks.
🦀 Rustacean? Check out htms-rs
How it works
- Tokenizer: scans HTML for
data-htms. - Resolver: maps names to async functions.
- Serializer: streams HTML and emits chunks as tasks finish.
- Client runtime: swaps placeholders and cleans up markers.
Result: SEO-friendly streaming HTML with minimal overhead.
Try the curl optimized demo
$ curl -N https://htms.skarab42.dev/curlTry the (too much) dashboard demo
🚀 Quick start
1. Install
Use your preferred package manager to install the plugin:
pnpm add htms-js2. HTML with placeholders
<!-- home-page.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>3. Async tasks
// home-page.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>`;
}4. Stream it (Express)
import { Writable } from 'node:stream';
import Express from 'express';
import { createHtmsFileModulePipeline } from 'htms-js';
const app = Express();
app.get('/', async (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
await createHtmsFileModulePipeline('./home-page.html').pipeTo(Writable.toWeb(res));
});
app.listen(3000);Visit http://localhost:3000: content renders immediately, then fills itself in.
About module resolution
When you call createHtmsFileModulePipeline('./home-page.html'), HTMS will automatically look for a sibling module file named ./home-page.js and resolve tasks from there. If you want to:
- Mix several modules on the same page → see Scoped modules.
- Point to another file → use the
specifieroption in the API. - Provide your own logic → see Custom resolvers.
Examples
- Express, Fastify, Hono
- Raw streaming (stdout)
- htms server (cli)
git clone https://github.com/skarab42/htms-js.git
cd htms-js
pnpm i && pnpm build# run from repo root, pick one:
pnpm --filter server-example start # published dashboard demo at https://htms.skarab42.dev
pnpm --filter hono-example start
pnpm --filter fastify-example start
pnpm --filter express-example start
pnpm --filter stdout-example startHTMS attributes
You can omit the
data-prefix, though using it is more in line with standard HTML practices.
Scoped modules (data-htms-module)
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 | Accessibility (aria-busy) |
| --------- | --------------------------------------------------- | ---------------------------- | --------------------------- |
| replace | Replace the placeholder node (outer) | host.replaceWith(frag) | No |
| content | Replace the children of the placeholder (inner) | host.replaceChildren(frag) | Yes |
| append | Append result as last child | host.append(frag) | Yes |
| prepend | Insert result as first child | host.prepend(frag) | Yes |
| before | Insert result before the placeholder | host.before(frag) | No |
| after | Insert result after the placeholder | host.after(frag) | No |
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. - With
content,append, orprepend, the container is kept (useful for accessibility/live regions).
Accessibility (content, append, prepend modes)
When data-htms-commit="content", append, or prepend 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 the first update, sets
aria-busy="false", so screen readers announce the content as soon as it arrives. - Screen readers will announce any further chunks (additional updates) directly, since
aria-busystaysfalse.
This gives you accessible announcements out of the box, without extra markup.
Tips for accessibility:
- If accessibility is a priority, avoid too many updates: prefer a single update ("one shot") if the task is fast enough to prevent excessive announcements.
Task API: api.commit(...)
Use api.commit(html, options?) inside a task to push partial updates before the final return.
Minimal shape (JS):
// In a task: (value, api) => Promise<string>
// default mode when options are omitted: 'append'
api.commit('<div>chunk</div>');
api.commit('<div>chunk</div>', { mode: 'append' }); // mode: 'content' | 'append' | 'prepend' | 'before' | 'after'Type definitions (TypeScript):
export type CommitMode = 'replace' | 'content' | 'append' | 'prepend' | 'before' | 'after';
export interface TaskApiOptions {
// Optional. If omitted, commit defaults to mode: 'append'.
mode?: Exclude<CommitMode, 'replace'>;
}
export interface TaskApi {
// 'replace' is not allowed for partial commits:
// the host's UUID anchors subsequent updates; replacing the host would break future commits.
commit(html: string, options?: TaskApiOptions): void;
}
export type Task<Value = unknown> = (value: Value, api: TaskApi) => PromiseLike<string>;Rules:
- Allowed modes:
content,append,prepend,before,after(notreplace). - Default mode for partial commits when options are omitted:
append. - Why not
replace: partial commits must target the original host by UUID; replacing the host would drop that anchor and break subsequent commits. - Each
api.commit(...)emits a partial chunk applied immediately on the client. - The final HTML is the task’s return value and uses the host’s
data-htms-commit. Withdata-htms-commit="append", returning''performs no DOM change (just cleanup).
Example (stream items with append):
<!-- Host is the final container; we append <li> items into it -->
<ul data-htms="loadFeed" data-htms-commit="append">
<li>Loading…</li>
</ul>// tasks.js
export async function loadFeed(_value, api) {
// Clear the placeholder once (optional if already empty)
api.commit('', { mode: 'content' });
// Any async iterable of items
for await (const item of getFeedAsyncIterable()) {
// options omitted → defaults to 'append'
api.commit(`<li>${item.title}</li>`);
}
// Final empty return with host commit="append" → no DOM change, just cleanup
return '';
}Notes:
- Fragments must be well‑formed HTML. Use the host as the container; don’t stream unbalanced tags.
content,append, andprependautomatically manage live-region ARIA. If you need announcements, use these modes.
Under the hood (advanced)
A classic htms pipeline.
import process from 'node:process';
import { Writable } from 'node:stream';
import {
createFileStream,
createHtmsResolver,
createHtmsSerializer,
createHtmsTokenizer,
ModuleResolver,
} from 'htms-js';
const resolver = new ModuleResolver('./tasks.js');
await createFileStream('./index.html')
.pipeThrough(createHtmsTokenizer())
.pipeThrough(createHtmsResolver(resolver))
.pipeThrough(createHtmsSerializer())
.pipeTo(Writable.toWeb(process.stdout));Works anywhere with a WritableStream: File, HTTP, network, stdout, ...
Building blocks
// Streams
createStringStream(input: string | string[]): ReadableStream<string>
createFileStream(filePath: string): ReadableStream<string>
// Core transforms
createHtmsTokenizer(): TransformStream<string, Token>
createHtmsResolver(resolver: Resolver): TransformStream<Token, ResolverToken>
createHtmsSerializer(): TransformStream<ResolverToken, string>
createHtmsCompressor(encoding: Encoding): TransformStream<string, string | Buffer>
// Pipelines
createHtmsStringPipeline(html: string, resolver: Resolver): ReadableStream<string>
createHtmsFilePipeline(filePath: string, resolver: Resolver): ReadableStream<string>
createHtmsStringModulePipeline(html: string, moduleSpecifier: string): ReadableStream<string>
createHtmsFileModulePipeline(filePath: string, opts?: { specifier?: string; extension?: string }): ReadableStream<string>API
createHtmsFileModulePipeline
createHtmsFileModulePipeline(
filePath: string,
options?: {
specifier?: string;
extension?: string;
basePath?: string;
cacheModule?: boolean;
}
): ReadableStream<string>filePath: path to HTML with placeholders.options.specifier: relative module path.options.extension: auto-derive tasks module by swapping extension (default:js).options.basePath: module base path.options.cacheModule: enable module caching.
Resolution rules
- Uses
require.resolve+ dynamic import. - Supports
.ts,.mts,.ctsif your runtime allows it. - Task names = HTML placeholders.
- Named exports or a default export object are valid.
Custom resolvers
A resolver follows the minimal Resolver contract. It doesn't run tasks, only returns a function the serializer will call.
export type Task = () => PromiseLike<string>;
export interface TaskInfo {
name: string;
uuid: string;
}
export interface Resolver {
resolve(info: TaskInfo): Task | Promise<Task>;
}Example: MapResolver
import { createFileStream, createHtmsTokenizer, createHtmsResolver, createHtmsSerializer } from 'htms-js';
import { Writable } from 'node:stream';
class MapResolver {
#map = new Map<string, () => Promise<string>>([
[
'foo',
async () => {
await new Promise((r) => setTimeout(r, 200));
return '<strong>Foo ✓</strong>';
},
],
[
'bar',
async () => {
await new Promise((r) => setTimeout(r, 400));
return '<em>Bar ✓</em>';
},
],
]);
resolve(info: { name: string }) {
const task = this.#map.get(info.name);
if (!task) {
return () => Promise.reject(new Error(`Unknown task: ${info.name}`));
}
return task;
}
}
await createFileStream('./index.html')
.pipeThrough(createHtmsTokenizer())
.pipeThrough(createHtmsResolver(new MapResolver()))
.pipeThrough(createHtmsSerializer())
.pipeTo(Writable.toWeb(process.stdout));Notes
resolve(info)can return a Task or Promise.- A Task must return a string (HTML) or a promise resolving to one.
- Prefer returning a rejecting task over throwing inside
resolve(). - Resolvers can call APIs, databases, or microservices.
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


