From c39f2890d69de460b780fc63d8696e0b719eb794 Mon Sep 17 00:00:00 2001 From: Thomas Stokes Date: Wed, 19 Mar 2025 23:29:41 +0800 Subject: [PATCH] ssr: html stream injection --- src/server.ts | 139 +++++++++++++++++++--- src/server/tests/basic.test.ts | 72 +++++------ src/server/tests/inject-to-stream.test.ts | 30 +++++ src/server/tests/recursion.test.ts | 10 +- src/server/tests/renderable.test.ts | 12 +- 5 files changed, 199 insertions(+), 64 deletions(-) create mode 100644 src/server/tests/inject-to-stream.test.ts diff --git a/src/server.ts b/src/server.ts index 20203c45..383056f5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,19 @@ -import { assert, is_html, is_iterable, is_renderable, lexer, single_part_template, type Displayable } from './shared.ts' +import type { HTML } from './index.ts' +import { + assert, + is_html, + is_iterable, + is_renderable, + lexer, + single_part_template, + type Displayable, + type Renderable, +} from './shared.ts' interface PartRenderer { replace_start: number replace_end: number - render: (values: unknown[]) => string | Generator + render: (values: unknown[], controller: Controller) => string | Generator } interface CompiledTemplate { @@ -69,7 +79,7 @@ function compile_template(statics: TemplateStringsArray): CompiledTemplate { compiled.parts.push({ replace_start: offset, replace_end: offset + 1, - render: values => render_child(values[i]), + render: (values, controller) => render_child(values[i], controller), }) break @@ -116,6 +126,18 @@ function compile_template(statics: TemplateStringsArray): CompiledTemplate { return compiled } +interface Controller { + injections: Promise[] +} + +const controllers = new WeakMap() + +export function injectToStream(renderable: Renderable, markup: HTML | Promise): void { + const controller = controllers.get(renderable) + assert(controller, 'the renderable has not been rendered') + controller.injections.push(Promise.resolve(markup)) +} + function render_directive(value: unknown) { if (value === null) return '' @@ -133,16 +155,19 @@ function render_attribute(name: string, value: unknown) { return `${name}="${escape(value)}"` } -function* render_child(value: unknown) { - const seen = new Map() +function* render_child(value: unknown, controller: Controller) { + const seen = new WeakMap() while (is_renderable(value)) try { - const times = seen.get(value) ?? 0 + const renderable = value + + const times = seen.get(renderable) ?? 0 if (times > 100) throw new Error('circular render') - seen.set(value, times + 1) + seen.set(renderable, times + 1) - value = value.render() + controllers.set(renderable, controller) + value = renderable.render() } catch (thrown) { if (is_html(thrown)) { value = thrown @@ -152,9 +177,9 @@ function* render_child(value: unknown) { } if (is_iterable(value)) { - for (const item of value) yield* render_to_iterable(item as Displayable) + for (const item of value) yield* render_to_iterable(item as Displayable, controller) } else if (is_html(value)) { - yield* render_to_iterable(value) + yield* render_to_iterable(value, controller) } else if (value !== null) { yield escape(value) } @@ -172,7 +197,7 @@ function escape(str: unknown) { return String(str).replace(ESCAPE_RE, c => ESCAPE_SUBSTITUTIONS[c as keyof typeof ESCAPE_SUBSTITUTIONS]) } -function* render_to_iterable(value: Displayable) { +function* render_to_iterable(value: Displayable, controller: Controller) { const { _statics: statics, _dynamics: dynamics } = is_html(value) ? value : single_part_template(value) const template = compile_template(statics) @@ -185,36 +210,106 @@ function* render_to_iterable(value: Displayable) { let prev_end = 0 for (const { replace_start, replace_end, render } of template.parts) { yield str.slice(prev_end, replace_start) - yield* render(dynamics) + yield* render(dynamics, controller) prev_end = replace_end } yield str.slice(prev_end) } -export function renderToString(value: Displayable): string { +async function* read_all_promises(promises: Promise[]): AsyncGenerator { + type RecursivePromise = Promise<{ + value: T + promise: RecursivePromise + }> + + const pending = new Set( + promises.map(p => { + const promise: RecursivePromise = p.then(value => ({ value, promise })) + return promise + }), + ) + + while (pending.size) { + const { value, promise } = await Promise.race(pending) + pending.delete(promise) + yield value + } +} + +export async function renderToString(value: Displayable): Promise { + const render_controller: Controller = { injections: [] } let str = '' - for (const part of render_to_iterable(value)) str += part + + for (const part of render_to_iterable(value, render_controller)) { + str += part + } + + const count = render_controller.injections.length + for await (const injection of read_all_promises(render_controller.injections)) { + for (const part of render_to_iterable(injection, render_controller)) { + str += part + } + } + assert( + count === render_controller.injections.length, + 'calling injectToStream from an injection is currently not supported', + ) + return str } export function renderToReadableStream(value: Displayable): ReadableStream { - const iter = render_to_iterable(value) + const render_controller: Controller = { injections: [] } + const iter = render_to_iterable(value, render_controller) + return new ReadableStream({ - pull(controller) { + async pull(controller) { const { done, value } = iter.next() + if (done) { + const count = render_controller.injections.length + for await (const injection of read_all_promises(render_controller.injections)) { + for (const part of render_to_iterable(injection, render_controller)) { + controller.enqueue(part) + } + } + assert( + count === render_controller.injections.length, + 'calling injectToStream from an injection is currently not supported', + ) controller.close() return } + controller.enqueue(value) }, }).pipeThrough(new TextEncoderStream()) } // { +// const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +// const suspends = { +// render() { +// const id = 'abcd' +// injectToStream( +// this, +// sleep(250).then( +// () => +// html``, +// ), +// ) +// throw html`` +// }, +// } + // const displayable = html` +// +//

a${'text'.repeat(1000)}b

// -//

a${'text'}b

+//
this suspends: ${suspends}
// {}}> // // + `, + ) + throw html`Loading...` + }, + } + + const expected = `
Loading...
Loaded
` + assert.equal(await renderToString(html`
${loading}
`), expected) + + const stream = renderToReadableStream(html`
${loading}
`) + assert.equal(await new Response(stream).text(), expected) +}) diff --git a/src/server/tests/recursion.test.ts b/src/server/tests/recursion.test.ts index faa0201c..1ac81ad0 100644 --- a/src/server/tests/recursion.test.ts +++ b/src/server/tests/recursion.test.ts @@ -1,10 +1,10 @@ import { html } from 'dhtml' +import { renderToString } from 'dhtml/server' import { assert_eq, test } from '../../../scripts/test/test.ts' -import { renderToString } from '../../server.ts' const DEPTH = 10 -test('basic recursion is handled correctly', () => { +test('basic recursion is handled correctly', async () => { const app = { renders: 0, render() { @@ -12,10 +12,10 @@ test('basic recursion is handled correctly', () => { return this }, } - assert_eq(renderToString(app), 'hello!') + assert_eq(await renderToString(app), 'hello!') }) -test('nested recursion is handled correctly', () => { +test('nested recursion is handled correctly', async () => { const app = { renders: 0, render() { @@ -23,5 +23,5 @@ test('nested recursion is handled correctly', () => { return html`${this}` }, } - assert_eq(renderToString(app), ''.repeat(DEPTH) + 'hello!' + ''.repeat(DEPTH)) + assert_eq(await renderToString(app), ''.repeat(DEPTH) + 'hello!' + ''.repeat(DEPTH)) }) diff --git a/src/server/tests/renderable.test.ts b/src/server/tests/renderable.test.ts index 7ec006a8..352d4510 100644 --- a/src/server/tests/renderable.test.ts +++ b/src/server/tests/renderable.test.ts @@ -2,9 +2,9 @@ import { html } from 'dhtml' import { renderToString } from 'dhtml/server' import { assert, assert_eq, test } from '../../../scripts/test/test.ts' -test('renderables work correctly', () => { +test('renderables work correctly', async () => { assert_eq( - renderToString( + await renderToString( html`${{ render() { return html`

Hello, world!

` @@ -15,10 +15,10 @@ test('renderables work correctly', () => { ) }) -test('thrown errors directly propagate', () => { +test('thrown errors directly propagate', async () => { const oops = new Error('oops') try { - renderToString( + await renderToString( html`${{ render() { throw oops @@ -29,9 +29,9 @@ test('thrown errors directly propagate', () => { } catch {} }) -test('renderables can throw instead of returning', () => { +test('renderables can throw instead of returning', async () => { assert_eq( - renderToString({ + await renderToString({ render() { throw html`this was thrown` },