From 2301c5d631846c4a55805c67eab7e3517f369db7 Mon Sep 17 00:00:00 2001 From: mux Date: Sat, 1 Nov 2025 12:15:21 -0300 Subject: [PATCH] refactor --- deno.json | 8 +++ src/html.ts | 147 +++++++++++++++++++++++++------------------- src/http.ts | 75 +++++++++++------------ src/main.ts | 9 ++- src/stream.ts | 166 +++++++++++++++++++++----------------------------- 5 files changed, 203 insertions(+), 202 deletions(-) diff --git a/deno.json b/deno.json index 02289d8..cf2b718 100644 --- a/deno.json +++ b/deno.json @@ -10,6 +10,14 @@ "exclude": ["no-explicit-any", "require-await"] } }, + "compilerOptions": { + "lib": [ + "deno.ns", + "esnext", + "dom", + "dom.iterable" + ] + }, "imports": { "@std/assert": "jsr:@std/assert@1" } diff --git a/src/html.ts b/src/html.ts index 3aa52a9..a0f3aef 100644 --- a/src/html.ts +++ b/src/html.ts @@ -3,102 +3,127 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { type Chunk, ChunkedWriter } from "./http.ts"; +import { type Chunk } from "./http.ts"; import { ChunkedStream } from "./stream.ts"; type Attrs = Record; -const SELF_CLOSING_TAGS = new Set([ +const VOID_TAGS = new Set([ + "area", + "base", "br", + "col", "embed", "hr", "img", "input", "link", "meta", - "track", + "param", "source", + "track", + "wbr", ]); +const ESCAPE_MAP: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + export function escape(input: string): string { - return input - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + let result = ""; + let lastIndex = 0; + + for (let i = 0; i < input.length; i++) { + const replacement = ESCAPE_MAP[input[i]]; + if (replacement) { + result += input.slice(lastIndex, i) + replacement; + lastIndex = i + 1; + } + } + + return lastIndex ? result + input.slice(lastIndex) : input; } -function attrsToString( - attrs: Attrs | undefined, - escape: (str: string) => string, -): string { +function serialize(attrs: Attrs | undefined): string { if (!attrs) return ""; - const pairs = Object.entries(attrs) - .filter(([_, value]) => value !== undefined && value !== null) - .map(([key, value]) => { - if (value === true) return key; - if (value === false) return ""; - return `${key}="${escape(String(value))}"`; - }) - .filter(Boolean); - return pairs.length ? " " + pairs.join(" ") : ""; + let output = ""; + + for (const key in attrs) { + const val = attrs[key]; + if (val == null || val === false) continue; + output += " "; + output += val === true ? key : `${key}="${escape(String(val))}"`; + } + + return output; } -type TagFunction = { - (attrs: Attrs, ...children: Chunk[]): Promise; - (...children: Chunk[]): Promise; +type TagRes = void | Promise; + +type TagFn = { + (attrs: Attrs, ...children: Chunk[]): TagRes; + (...children: Chunk[]): TagRes; + (template: TemplateStringsArray, ...values: Chunk[]): TagRes; + (fn: () => any): TagRes; }; -type HtmlBuilder = { - [K: string]: TagFunction; +type HtmlProxy = { [K in keyof HTMLElementTagNameMap]: TagFn } & { + [key: string]: TagFn; }; +const isTemplateLiteral = (arg: any): arg is TemplateStringsArray => + Array.isArray(arg) && "raw" in arg; + +const isAttributes = (arg: any): arg is Record => + arg && typeof arg === "object" && !isTemplateLiteral(arg); + +async function render(child: any): Promise { + if (child == null) return ""; + if (typeof child === "string" || typeof child === "number") { + return String(child); + } + if (child instanceof Promise) return render(await child); + if (Array.isArray(child)) { + return (await Promise.all(child.map(render))).join(""); + } + if (typeof child === "function") return render(await child()); + return String(child); +} + export function html( chunks: ChunkedStream, - write: ChunkedWriter, -): HtmlBuilder { - const tags = new Map Promise>(); +): HtmlProxy { + const cache = new Map(); + const write = (buf: string) => !chunks.closed && chunks.write(buf); - const handler: ProxyHandler> = { + const handler: ProxyHandler> = { get(_, tag: string) { - if (tags.has(tag)) { - return tags.get(tag); - } + let fn = cache.get(tag); + if (fn) return fn; - const fn = async (...args: (Chunk | Attrs)[]) => { - const isTemplate = args.length === 1 && Array.isArray(args[0]) && - "raw" in args[0]; - const hasAttrs = !isTemplate && args.length && - typeof args[0] === "object"; + fn = async (...args: any[]) => { + const attrs = isAttributes(args[0]) ? args.shift() : undefined; - const attrs: Attrs | undefined = hasAttrs - ? args.shift() as Attrs - : undefined; - const children: Chunk[] = args as Chunk[]; + const isVoid = VOID_TAGS.has(tag.toLowerCase()); + const attributes = serialize(attrs); - const attributes = attrsToString(attrs, escape); - const isSelfClosing = SELF_CLOSING_TAGS.has(tag.toLowerCase()); - if (!isSelfClosing && !children.length) return; - chunks.write(`<${tag}${attributes}${isSelfClosing ? " /" : ""}>`); + write(`<${tag}${attributes}${isVoid ? "/" : ""}>`); + if (isVoid) return; - if (!isSelfClosing) { - if (isTemplate) { - await (write as any)(...children); - } else { - for (const child of children) { - typeof child === "function" - ? await (child as any)() - : chunks.write(String(await child)); - } - } - chunks.write(``); + for (const child of args) { + write(await render(child)); } + + write(``); }; - tags.set(tag, fn); - return fn; + return cache.set(tag, fn), fn; }, }; - return new Proxy({}, handler); + + return new Proxy({}, handler) as HtmlProxy; } diff --git a/src/http.ts b/src/http.ts index 268de93..a3d267c 100644 --- a/src/http.ts +++ b/src/http.ts @@ -21,17 +21,17 @@ async function* normalize( value: Chunk | undefined | null, ): AsyncIterable { if (value == null) return; + if (typeof value === "string") { + 3; yield value; } else if (value instanceof Promise) { const resolved = await value; if (resolved != null) yield String(resolved); - } else if ( - typeof value === "object" && - (Symbol.asyncIterator in value || Symbol.iterator in value) - ) { + } else if (Symbol.asyncIterator in value || Symbol.iterator in value) { for await (const chunk of value as AsyncIterable) { if (chunk != null) yield String(chunk); + 1; } } else { yield String(value); @@ -43,66 +43,65 @@ export type ChunkedWriter = ( ...values: Chunk[] ) => Promise; -export function makeChunkWriter(stream: ChunkedStream): ChunkedWriter { - const emit = (chunk: string) => { - if (stream.closed) return; - chunk === "EOF" ? stream.close() : stream.write(chunk); - }; +export const makeChunkWriter = + (stream: ChunkedStream): ChunkedWriter => + async (strings, ...values) => { + const emit = (chunk: string) => + !stream.closed && + (chunk === "EOF" ? stream.close() : stream.write(chunk)); - return async function (strings: TemplateStringsArray, ...values: Chunk[]) { for (let i = 0; i < strings.length; i++) { - if (strings[i]) emit(strings[i]); + strings[i] && emit(strings[i]); for await (const chunk of normalize(values[i])) { emit(chunk); } } }; -} -export function chunkedHtml(): { - chunks: ChunkedStream; - stream: ReadableStream; -} { - const encoder = new TextEncoder(); +export function chunkedHtml() { const chunks = new ChunkedStream(); const stream = new ReadableStream({ - async pull(controller) { - if (chunks.closed) return; - const result = await chunks.next(); - result.done - ? controller.close() - : controller.enqueue(encoder.encode(result.value)); - }, - cancel() { - chunks.close(); + async start(controller) { + const encoder = new TextEncoder(); + for await (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); }, + cancel: chunks.close, }); return { chunks, stream }; } +const DOCUMENT_TYPE = ""; +const HTML_BEGIN = (lang: string) => + ``; +const HEAD_END = " - - - - -${options.headContent || ""} - -`; + 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, - stream, + blob: stream, chunks, close() { - if (chunks.closed) return; - chunks.write(""); - chunks.close(); + if (!chunks.closed) { + chunks.write(HTML_END); + chunks.close(); + } }, get response(): Response { return new Response(stream, { diff --git a/src/main.ts b/src/main.ts index 28cb658..e59c383 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,19 +10,18 @@ Deno.serve({ port: 8080, async handler() { const stream = await createHtmlStream({ lang: "en" }); - const { h1, p, li } = html(stream.chunks, stream.write); + const { h1, ol, p, li } = html(stream.chunks); - await h1`

Normal Streaming Page

`; + await h1`Normal Streaming Page`; await p({ class: "oh hey" }, "meowing chunk by chunk"); - (async () => { + ol(async () => { const fruits = ["Apple", "Banana", "Cherry"]; - for (const fruit of fruits) { await new Promise((r) => setTimeout(r, 500)); await li(fruit); } - })(); + }); return stream.response; }, diff --git a/src/stream.ts b/src/stream.ts index 78081b3..8683ac8 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -5,38 +5,37 @@ export class ChunkedStream implements AsyncIterable { private readonly chunks: T[] = []; - private readonly queue: ((result: IteratorResult) => void)[] = []; + private readonly resolvers: ((result: IteratorResult) => void)[] = []; private _closed = false; get closed(): boolean { return this._closed; } - write(chunk: T): void { + write(chunk: T) { if (this._closed) throw new Error("Cannot write to closed stream"); - this.queue.shift()?.({ value: chunk, done: false }) ?? + + const resolver = this.resolvers.shift(); + if (resolver) { + resolver({ value: chunk, done: false }); + } else { this.chunks.push(chunk); + } } close(): void { this._closed = true; - this.queue.splice(0).forEach((r) => r({ value: undefined, done: true })); + while (this.resolvers.length) { + this.resolvers.shift()!({ value: undefined! as any, done: true }); + } } - next(): Promise> { + async next(): Promise> { if (this.chunks.length) { - return Promise.resolve({ - value: this.chunks.shift()!, - done: false, - }); + return { value: this.chunks.shift()!, done: false }; } - if (this._closed) { - return Promise.resolve({ - value: undefined as any, - done: true, - }); - } - return new Promise((resolve) => this.queue.push(resolve)); + if (this._closed) return { value: undefined as any, done: true }; + return new Promise((resolve) => this.resolvers.push(resolve)); } [Symbol.asyncIterator](): AsyncIterableIterator { @@ -44,94 +43,65 @@ export class ChunkedStream implements AsyncIterable { } } -export async function* mapStream( - source: AsyncIterable, - fn: (chunk: T, index: number) => U | Promise, -): AsyncIterable { - let index = 0; - for await (const chunk of source) { - yield await fn(chunk, index++); - } -} +export const mapStream = ( + fn: (chunk: T, i: number) => U | Promise, +) => + async function* (source: AsyncIterable): AsyncIterable { + let i = 0; + for await (const chunk of source) yield await fn(chunk, i++); + }; -export async function* filterStream( - source: AsyncIterable, - predicate: (chunk: T, index: number) => boolean | Promise, -): AsyncIterable { - let index = 0; - for await (const chunk of source) { - if (await predicate(chunk, index++)) { +export const filterStream = ( + pred: (chunk: T, i: number) => boolean | Promise, +) => + async function* (source: AsyncIterable): AsyncIterable { + let i = 0; + for await (const chunk of source) { + if (await pred(chunk, i++)) yield chunk; + } + }; + +export const takeStream = (count: number) => + async function* (source: AsyncIterable): AsyncIterable { + let taken = 0; + for await (const chunk of source) { + if (taken++ >= count) return; yield chunk; } - } -} + }; -export async function* composeStreams( - ...sources: AsyncIterable[] -): AsyncIterable { - for (const source of sources) { - yield* source; - } -} +export const skipStream = (count: number) => + async function* (source: AsyncIterable): AsyncIterable { + let i = 0; + for await (const chunk of source) { + if (i++ >= count) yield chunk; + } + }; -export async function* takeStream( - source: AsyncIterable, - count: number, -): AsyncIterable { - let taken = 0; - for await (const chunk of source) { - if (taken++ >= count) break; - yield chunk; - } -} +export const batchStream = (size: number) => + async function* (source: AsyncIterable): AsyncIterable { + let batch: T[] = []; + for await (const chunk of source) { + batch.push(chunk); + if (batch.length >= size) { + yield batch; + batch = []; + } + } + batch.length && (yield batch); + }; -export async function* skipStream( - source: AsyncIterable, - count: number, -): AsyncIterable { - let skipped = 0; - for await (const chunk of source) { - if (skipped++ >= count) { +export const tapStream = ( + fn: (chunk: T, i: number) => void | Promise, +) => + async function* (source: AsyncIterable): AsyncIterable { + let i = 0; + for await (const chunk of source) { yield chunk; + await fn(chunk, i++); } - } -} + }; -export async function* batchStream( - source: AsyncIterable, - size: number, -): AsyncIterable { - let batch: T[] = []; - - for await (const chunk of source) { - batch.push(chunk); - if (batch.length >= size) { - yield batch, batch = []; - } - } - - if (batch.length > 0) { - yield batch; - } -} - -export async function* flatMapStream( - source: AsyncIterable, - fn: (chunk: T, index: number) => AsyncIterable | Iterable, -): AsyncIterable { - let index = 0; - for await (const chunk of source) { - yield* fn(chunk, index++); - } -} - -export async function* tapStream( - source: AsyncIterable, - fn: (chunk: T, index: number) => void | Promise, -): AsyncIterable { - let index = 0; - for await (const chunk of source) { - yield chunk; - await fn(chunk, index++); - } -} +export const pipe = + (...fns: Array<(src: AsyncIterable) => AsyncIterable>) => + (source: AsyncIterable) => fns.reduce((acc, fn) => fn(acc), source);