feat(html): streamable server templates — async holes in child position

The server tag now builds a parts list (string runs + async holes) instead
of an eager string. Sync templates collapse to one string and toString()
is byte-identical to before. Promises and async iterables in child
position make the result streamable: iterating it yields everything before
a hole immediately, then each hole as it resolves, in document order —
resolved values get full child semantics (templates, arrays, raw,
escaping). toString() on async content throws with streaming guidance;
async values in attribute position throw immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-08 18:43:40 +02:00
co-authored by Claude Fable 5
parent c9f5ba2fda
commit 9cf9e51ac9
2 changed files with 270 additions and 35 deletions
+192 -35
View File
@@ -1,7 +1,15 @@
// Ported from nanohtml 1.10.0 lib/server.js (MIT) — https://github.com/choojs/nanohtml
// Server-side tagged template: renders straight to an escaped HTML string.
// The browserify/babel transform branches of the original are gone for good —
// this is a pure runtime tag.
// Server-side tagged template. The browserify/babel transform branches of
// the original are gone for good — this is a pure runtime tag.
//
// v8 shape: the tag builds a parts list — static/escaped string runs
// interleaved with unresolved async holes (promises or async iterables in
// child position). A fully-sync template collapses to one string and
// toString() behaves exactly like v7. Async holes make the result
// streamable: iterate it with `for await` (each chunk is a string) — that
// is what choo's toStream() does. toString() on async content throws.
// Async values are only allowed in child position; attribute values must
// be synchronous.
const BOOL_PROPS = [
'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default',
@@ -14,57 +22,203 @@ const boolPropRx = new RegExp('([^-a-z](' + BOOL_PROPS.join('|') + '))=["\']?$',
const handlerRx = /[^-a-z](on[a-z]+)=$/i
const query = /(?:="|&)[^"]*=$/
// minimal parser state, only to distinguish child position from tag position
const TEXT = 0
const TAG = 1
const DQ = 2
const SQ = 3
const COMMENT = 4
function scan (s, state) {
for (let j = 0; j < s.length; j++) {
const c = s[j]
switch (state) {
case TEXT:
if (c === '<') {
if (s.startsWith('!--', j + 1)) { state = COMMENT; j += 3 } else state = TAG
}
break
case TAG:
if (c === '>') state = TEXT
else if (c === '"') state = DQ
else if (c === "'") state = SQ
break
case DQ:
if (c === '"') state = TAG
break
case SQ:
if (c === "'") state = TAG
break
case COMMENT:
if (c === '-' && s.startsWith('->', j + 1)) { state = TEXT; j += 2 }
break
}
}
return state
}
function isThenable (value) {
return value && typeof value.then === 'function'
}
function isAsyncIterable (value) {
return value && typeof value[Symbol.asyncIterator] === 'function'
}
export class HtmlChunks {
constructor (parts) {
this.parts = parts // strings and { async: value } holes
this.__encoded = true
}
get async () {
return this.parts.some((p) => typeof p !== 'string')
}
toString () {
let out = ''
for (const part of this.parts) {
if (typeof part !== 'string') {
throw new Error(
'@choojs/html: this template has async content and cannot render synchronously — stream it (choo: use toStream() instead of toString())'
)
}
out += part
}
return out
}
async * [Symbol.asyncIterator] () {
for (const part of this.parts) {
if (typeof part === 'string') {
yield part
} else {
yield * resolveAsync(part.async)
}
}
}
}
// Resolve an async hole to string chunks, applying child-value semantics
// to whatever it produces.
async function * resolveAsync (value) {
if (isThenable(value)) {
yield * streamChild(await value)
} else {
for await (const item of value) {
yield * streamChild(item)
}
}
}
async function * streamChild (value) {
if (value === null || value === undefined) return
// HtmlChunks is itself async-iterable: its chunks are final HTML and
// must never re-enter escaping, so check it before the generic paths
if (value instanceof HtmlChunks) {
yield * value[Symbol.asyncIterator]()
return
}
if (isThenable(value) || isAsyncIterable(value)) {
yield * resolveAsync(value)
return
}
if (Array.isArray(value)) {
for (const item of value) yield * streamChild(item)
return
}
const str = handleChild(value)
if (str !== '') yield str
}
export default function html (pieces, ...values) {
const parts = []
let acc = ''
let boolMatch
let output = ''
let state = TEXT
for (let i = 0; i < pieces.length; i++) {
const piece = pieces[i]
if (i < pieces.length - 1) {
// Event handlers are behavior, not markup: `onclick=${fn}` renders
// nothing at all, matching the browser renderer (which sets the
// handler as a property). v7 serialized a useless onclick="".
const handlerMatch = handlerRx.exec(piece)
if (handlerMatch && typeof values[i] === 'function') {
output += piece.slice(0, handlerMatch.index + 1).replace(/\s+$/, ' ')
continue
}
state = scan(piece, state)
if (i === pieces.length - 1) {
acc += piece
break
}
if ((boolMatch = boolPropRx.exec(piece))) {
output += piece.slice(0, boolMatch.index)
if (values[i]) {
output += boolMatch[1] + '="' + boolMatch[2] + '"'
const value = values[i]
if (state === TEXT) {
// child position: this is where async content may live
// (HtmlChunks is itself async-iterable, so check it first)
acc += piece
if (value instanceof HtmlChunks) {
// splice nested parts so inner async holes stream through
for (const part of value.parts) {
if (typeof part === 'string') acc += part
else { parts.push(acc, part); acc = '' }
}
continue
}
const value = handleValue(values[i])
if (piece[piece.length - 1] === '=' && !query.test(piece)) {
output += piece + '"' + value + '"'
} else if (isThenable(value) || isAsyncIterable(value)) {
parts.push(acc, { async: value })
acc = ''
} else {
output += piece + value
acc += handleChild(value)
}
continue
}
// tag position: attributes are synchronous, always
if (isThenable(value) || isAsyncIterable(value)) {
throw new Error('@choojs/html: async values are only allowed in child position, not in attributes')
}
// Event handlers are behavior, not markup: `onclick=${fn}` renders
// nothing at all, matching the browser renderer (which sets the
// handler as a property). v7 serialized a useless onclick="".
const handlerMatch = handlerRx.exec(piece)
if (handlerMatch && typeof value === 'function') {
acc += piece.slice(0, handlerMatch.index + 1).replace(/\s+$/, ' ')
continue
}
if ((boolMatch = boolPropRx.exec(piece))) {
acc += piece.slice(0, boolMatch.index)
if (value) {
acc += boolMatch[1] + '="' + boolMatch[2] + '"'
}
continue
}
const handled = handleValue(value)
if (piece[piece.length - 1] === '=' && !query.test(piece)) {
acc += piece + '"' + handled + '"'
} else {
output += piece
acc += piece + handled
}
}
// HACK: Avoid double encoding by marking encoded string
// You cannot add properties to string literals
// eslint-disable-next-line no-new-wrappers
const wrapper = new String(output)
wrapper.__encoded = true
return wrapper
parts.push(acc)
return new HtmlChunks(parts)
}
// child-position (text) value → string
function handleChild (value) {
if (Array.isArray(value)) return value.map(handleChild).join('')
if (typeof value === 'function') return ''
if (value === null || value === undefined) return ''
if (value.__encoded) return value.toString()
if (typeof value === 'object' && typeof value.outerHTML === 'string') return value.outerHTML
return escape(value.toString())
}
// tag-position (attribute) value → string; keeps v7 semantics including
// object spread and arrays
function handleValue (value) {
// Handle each item in array as potential unescaped value
if (Array.isArray(value)) return value.map(handleValue).join('')
// Ignore event handlers. `onclick=${(e) => doSomething(e)}`
// will become. `onclick=""`
if (typeof value === 'function') return ''
if (value === null || value === undefined) return ''
if (value.__encoded) return value
if (value.__encoded) return value.toString()
if (typeof value === 'object') {
if (typeof value.outerHTML === 'string') return value.outerHTML
@@ -86,7 +240,10 @@ function handleValue (value) {
}, '')
}
const str = value.toString()
return escape(value.toString())
}
function escape (str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
+78
View File
@@ -0,0 +1,78 @@
// Async holes and streaming in the server tag.
import { test } from 'node:test'
import assert from 'node:assert'
import html from '@choojs/html'
import raw from '@choojs/html/raw'
async function collect (chunks) {
const out = []
for await (const chunk of chunks) out.push(chunk)
return out
}
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms))
test('sync templates stream as a single chunk equal to toString()', async () => {
const res = html`<p>hello ${'world'}</p>`
const chunks = await collect(res)
assert.deepStrictEqual(chunks, ['<p>hello world</p>'])
assert.strictEqual(chunks.join(''), res.toString())
})
test('a promise child splits the stream at the hole', async () => {
const res = html`<main><h1>shell</h1>${wait(10, 'late')}<footer>end</footer></main>`
const chunks = await collect(res)
assert.strictEqual(chunks[0], '<main><h1>shell</h1>', 'everything before the hole flushes first')
assert.strictEqual(chunks.join(''), '<main><h1>shell</h1>late<footer>end</footer></main>')
})
test('toString() on async content throws with streaming guidance', () => {
const res = html`<p>${Promise.resolve('x')}</p>`
assert.throws(() => res.toString(), /toStream/)
})
test('resolved promises can carry templates, arrays, and raw', async () => {
const res = html`<ul>${wait(5, [html`<li>a</li>`, html`<li>${'<b>'}</li>`, raw('<li>raw</li>')])}</ul>`
const chunks = await collect(res)
assert.strictEqual(chunks.join(''), '<ul><li>a</li><li>&lt;b&gt;</li><li>raw</li></ul>')
})
test('nested templates with async holes stream through their parents', async () => {
const inner = html`<section>${wait(5, 'inner-late')}</section>`
const res = html`<div><p>early</p>${inner}</div>`
const chunks = await collect(res)
assert.strictEqual(chunks[0], '<div><p>early</p><section>', 'parent flushes up to the nested hole')
assert.strictEqual(chunks.join(''), '<div><p>early</p><section>inner-late</section></div>')
})
test('async iterable children stream chunk by chunk', async () => {
async function * rows () {
for (let i = 0; i < 3; i++) yield html`<li>${i}</li>`
}
const res = html`<ul>${rows()}</ul>`
const chunks = await collect(res)
assert.strictEqual(chunks.join(''), '<ul><li>0</li><li>1</li><li>2</li></ul>')
assert.ok(chunks.length >= 4, 'each yielded row is its own chunk')
})
test('multiple async holes resolve in document order', async () => {
// the second promise resolves first; output order must follow the document
const res = html`<div>${wait(20, 'A')}|${wait(5, 'B')}</div>`
const chunks = await collect(res)
assert.strictEqual(chunks.join(''), '<div>A|B</div>')
})
test('async values in attribute position throw immediately', () => {
assert.throws(
() => html`<div class=${Promise.resolve('x')}></div>`,
/child position/
)
})
test('resolved async strings are escaped like any child', async () => {
const res = html`<p>${wait(5, '<script>')}</p>`
const chunks = await collect(res)
assert.strictEqual(chunks.join(''), '<p>&lt;script&gt;</p>')
})