/** * Copyright (c) 2025 favewa * SPDX-License-Identifier: BSD-3-Clause */ import { ChunkedStream } from "./stream.ts"; export interface StreamOptions { headContent?: string; bodyAttributes?: string; lang?: string; } export type Chunk = | string | AsyncIterable | Promise | Iterable | null | undefined; async function* normalize( value: Chunk | undefined | null, ): AsyncIterable { if (value == null) return; if (typeof value === "string") { yield value; } else if (value instanceof Promise) { const resolved = await value; if (resolved != null) yield String(resolved); } else if (Symbol.asyncIterator in value || Symbol.iterator in value) { for await (const chunk of value as AsyncIterable) { if (chunk != null) yield String(chunk); } } else { yield String(value); } } export type ChunkedWriter = ( strings: TemplateStringsArray, ...values: Chunk[] ) => Promise; export const makeChunkWriter = (stream: ChunkedStream): ChunkedWriter => async (strings, ...values) => { const emit = (chunk: string) => !stream.closed && (chunk === "EOF" ? stream.close() : stream.write(chunk)); for (let i = 0; i < strings.length; i++) { strings[i] && emit(strings[i]); for await (const chunk of normalize(values[i])) { emit(chunk); } } }; export function chunkedHtml() { const chunks = new ChunkedStream(); const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); try { for await (const chunk of chunks) { controller.enqueue(encoder.encode(chunk)); } controller.close(); } catch (error) { controller.error(error); } }, cancel: chunks.close, }); return { chunks, stream }; } const DOCUMENT_TYPE = ""; const HTML_BEGIN = (lang: string) => ``; const HEAD_END = "; chunks: ChunkedStream; close(): void; error(err: Error): void; readonly response: Response; } export async function createHtmlStream( options: StreamOptions = {}, ): Promise { const { chunks, stream } = chunkedHtml(); const writer = makeChunkWriter(chunks); chunks.write(DOCUMENT_TYPE); chunks.write(HTML_BEGIN(options.lang || "en")); options.headContent && chunks.write(options.headContent); chunks.write(HEAD_END); options.bodyAttributes && chunks.write(" " + options.bodyAttributes); chunks.write(BODY_END); return { write: writer, blob: stream, chunks, close() { if (!chunks.closed) { chunks.write(HTML_END); chunks.close(); } }, error: chunks.error, response: new Response(stream, { headers: { "Content-Type": "text/html; charset=utf-8", "Transfer-Encoding": "chunked", "Cache-Control": "no-cache", Connection: "keep-alive", }, }), }; }