diff --git a/src/app.tsx b/src/app.tsx index d1560ac..a558e06 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -3,11 +3,24 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { close, open } from "interest/jsx-runtime"; + +async function* Fruits() { + const fruits = ["TSX", "Apple", "Banana", "Cherry"]; + yield open("ol"); + for (const fruit of fruits) { + await new Promise((r) => setTimeout(r, 500)); + yield
  • {fruit}
  • ; + } + yield close("ol"); +} + export default function App() { return ( <>

    JSX Page

    meowing chunk by chunk

    + ); } diff --git a/src/global.d.ts b/src/global.d.ts index 855ec42..3e1a77b 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -5,16 +5,37 @@ /// -import type { ChunkedStream } from "./stream.ts"; +import type { JsxElement } from "interest/jsx-runtime"; + +type HTMLAttributeMap = Partial< + Omit & { + style?: string; + class?: string; + children?: any; + [key: `data-${string}`]: string | number | boolean | null | undefined; + [key: `aria-${string}`]: string | number | boolean | null | undefined; + } +>; declare global { namespace JSX { - type Element = (chunks: ChunkedStream) => Promise; - interface IntrinsicElements { - [key: string]: ElementProps; - } - interface ElementProps { - [key: string]: any; + type Element = JsxElement; + + export interface ElementChildrenAttribute { + // deno-lint-ignore ban-types + children: {}; } + + export type IntrinsicElements = + & { + [K in keyof HTMLElementTagNameMap]: HTMLAttributeMap< + HTMLElementTagNameMap[K] + >; + } + & { + [K in keyof SVGElementTagNameMap]: HTMLAttributeMap< + SVGElementTagNameMap[K] + >; + }; } } diff --git a/src/html.ts b/src/html.ts index fcb9bdd..f84ff32 100644 --- a/src/html.ts +++ b/src/html.ts @@ -6,9 +6,12 @@ import { type Chunk } from "./http.ts"; import { ChunkedStream } from "./stream.ts"; -type Attrs = Record; +export type Attrs = Record< + string, + string | number | boolean | null | undefined +>; -const VOID_TAGS = new Set([ +export const VOID_TAGS = new Set([ "area", "base", "br", @@ -66,6 +69,7 @@ type TagRes = void | Promise; type TagFn = { (attrs: Attrs, ...children: Chunk[]): TagRes; + (attrs: Attrs, fn: () => any): TagRes; (...children: Chunk[]): TagRes; (template: TemplateStringsArray, ...values: Chunk[]): TagRes; (fn: () => any): TagRes; @@ -73,30 +77,33 @@ type TagFn = { export type HtmlProxy = { [K in keyof HTMLElementTagNameMap]: TagFn } & { [key: string]: TagFn; -} & { render(child: any): Promise }; +}; 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); + arg && typeof arg === "object" && !isTemplateLiteral(arg) && + !Array.isArray(arg) && !(arg instanceof Promise); -async function render(child: any): Promise { +async function render(child: unknown): Promise { if (child == null) return ""; - if (typeof child === "string" || typeof child === "number") { - return String(child); - } + + if (typeof child === "string") return escape(child); + if (typeof child === "number") return String(child); + if (typeof child === "boolean") return String(Number(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); + return escape(String(child)); } -export function html( - chunks: ChunkedStream, -): HtmlProxy { +export function html(chunks: ChunkedStream): HtmlProxy { const cache = new Map(); const write = (buf: string) => !chunks.closed && chunks.write(buf); @@ -105,11 +112,11 @@ export function html( let fn = cache.get(tag); if (fn) return fn; - fn = async (...args: any[]) => { + fn = async (...args: unknown[]) => { const attrs = isAttributes(args[0]) ? args.shift() : undefined; const isVoid = VOID_TAGS.has(tag.toLowerCase()); - const attributes = serialize(attrs); + const attributes = serialize(attrs as Attrs); write(`<${tag}${attributes}${isVoid ? "/" : ""}>`); if (isVoid) return; @@ -126,7 +133,5 @@ export function html( }; const proxy = new Proxy({}, handler) as HtmlProxy; - proxy.render = async (stuff: any) => void write(await render(stuff)); - return proxy; } diff --git a/src/http.ts b/src/http.ts index a3d267c..c27a95e 100644 --- a/src/http.ts +++ b/src/http.ts @@ -15,7 +15,9 @@ export type Chunk = | string | AsyncIterable | Promise - | Iterable; + | Iterable + | null + | undefined; async function* normalize( value: Chunk | undefined | null, @@ -23,7 +25,6 @@ async function* normalize( if (value == null) return; if (typeof value === "string") { - 3; yield value; } else if (value instanceof Promise) { const resolved = await value; @@ -31,7 +32,6 @@ async function* normalize( } 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); @@ -52,6 +52,7 @@ export const makeChunkWriter = for (let i = 0; i < strings.length; i++) { strings[i] && emit(strings[i]); + for await (const chunk of normalize(values[i])) { emit(chunk); } @@ -64,10 +65,14 @@ export function chunkedHtml() { const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); - for await (const chunk of chunks) { - controller.enqueue(encoder.encode(chunk)); + try { + for await (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } catch (error) { + controller.error(error); } - controller.close(); }, cancel: chunks.close, }); @@ -82,7 +87,18 @@ 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); @@ -103,15 +119,14 @@ export async function createHtmlStream(options: StreamOptions = {}) { chunks.close(); } }, - get response(): Response { - return new Response(stream, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Transfer-Encoding": "chunked", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }, - }); - }, + 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", + }, + }), }; } diff --git a/src/jsx.ts b/src/jsx.ts index d508e50..cb28938 100644 --- a/src/jsx.ts +++ b/src/jsx.ts @@ -3,27 +3,120 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { html, type HtmlProxy } from "./html.ts"; +import { Attrs, escape, html, VOID_TAGS } from "./html.ts"; import { ChunkedStream } from "./stream.ts"; -let context; - -export function jsx( - tag: string | typeof Fragment, - { children }: Record = {}, -) { - return async (chunks: ChunkedStream) => { - if (tag === Fragment) { - context = html(chunks); - for (const child of children) { - await context.render?.(child); - } - return; - } - await (context ||= html(chunks))[tag](...children); - }; -} - export const Fragment = Symbol("Fragment"); +type Props = Attrs & { children?: any }; +type Component = (props: Props) => JsxElement | AsyncGenerator; + +export type JsxElement = + | ((chunks: ChunkedStream) => Promise) + | AsyncGenerator; + +async function render( + child: any, + chunks: ChunkedStream, + context: ReturnType, +): Promise { + if (child == null || child === false || child === true) return; + + if (typeof child === "string") return chunks.write(escape(child)); + if (typeof child === "number") return chunks.write(String(child)); + + if (typeof child === "function") return await child(chunks); + if (child instanceof Promise) { + return await render(await child, chunks, context); + } + + if (typeof child === "object" && Symbol.asyncIterator in child) { + (async () => { + for await (const item of child) { + await render(item, chunks, context); + } + })(); + return; + } + + if (Array.isArray(child)) { + for (const item of child) await render(item, chunks, context); + return; + } + + chunks.write(escape(String(child))); +} + +export function jsx( + tag: string | Component | typeof Fragment, + props: Props | null = {}, +): JsxElement { + props ||= {}; + + return async (chunks: ChunkedStream) => { + const context = html(chunks); + const { children, ...attrs } = props; + + if (tag === Fragment) { + if (!Array.isArray(children)) { + return await render([children], chunks, context); + } + for (const child of children) { + await render(child, chunks, context); + } + return; + } + + if (typeof tag === "function") { + return await render(tag(props), chunks, context); + } + + const childr = children == null ? [] : [].concat(children); + const attributes = Object.keys(attrs).length ? attrs : {}; + + if (!childr.length || VOID_TAGS.has(tag)) { + await context[tag](childr); + } else { + await context[tag](attributes, async () => { + for (const child of childr) { + await render(child, chunks, context); + } + }); + } + }; +} + export const jsxs = jsx; + +async function renderJsx( + element: JsxElement | JsxElement[], + chunks: ChunkedStream, +): Promise { + if (Array.isArray(element)) { + for (const el of element) { + await renderJsx(el, chunks); + } + return; + } + if (typeof element === "object" && Symbol.asyncIterator in element) { + for await (const item of element) { + await renderJsx(item, chunks); + } + return; + } + if (typeof element === "function") { + await element(chunks); + } +} + +export const raw = + (html: string): JsxElement => async (chunks: ChunkedStream) => + void (!chunks.closed && chunks.write(html)); + +export const open = (tag: K) => + raw(`<${tag}>`); + +export const close = (tag: K) => + raw(``); + +export { renderJsx as render }; diff --git a/src/main.ts b/src/main.ts index d5772a7..5423546 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,26 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { html } from "./html.ts"; import { createHtmlStream } from "./http.ts"; import App from "./app.tsx"; +import { render } from "interest/jsx-runtime"; Deno.serve({ port: 8080, async handler() { const stream = await createHtmlStream({ lang: "en" }); - const { ol, li } = html(stream.chunks); - - await App()(stream.chunks); - - ol(async () => { - const fruits = ["TSX support", "Apple", "Banana", "Cherry"]; - for (const fruit of fruits) { - await new Promise((r) => setTimeout(r, 500)); - await li(fruit); - } - }); - + await render(App(), stream.chunks); return stream.response; }, }); diff --git a/src/stream.ts b/src/stream.ts index 8683ac8..58c7eaa 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -5,7 +5,11 @@ export class ChunkedStream implements AsyncIterable { private readonly chunks: T[] = []; + private readonly resolvers: ((result: IteratorResult) => void)[] = []; + private readonly rejectors: ((error: Error) => void)[] = []; + + private _error: Error | null = null; private _closed = false; get closed(): boolean { @@ -17,6 +21,7 @@ export class ChunkedStream implements AsyncIterable { const resolver = this.resolvers.shift(); if (resolver) { + this.rejectors.shift(); resolver({ value: chunk, done: false }); } else { this.chunks.push(chunk); @@ -26,16 +31,37 @@ export class ChunkedStream implements AsyncIterable { close(): void { this._closed = true; while (this.resolvers.length) { + this.rejectors.shift(); this.resolvers.shift()!({ value: undefined! as any, done: true }); } } + error(err: Error): void { + if (this._closed) return; + + this._error = err; + this._closed = true; + + while (this.rejectors.length) { + this.rejectors.shift()!(err); + this.resolvers.shift(); + } + } + async next(): Promise> { + if (this._error) { + throw this._error; + } + if (this.chunks.length) { return { value: this.chunks.shift()!, done: false }; } if (this._closed) return { value: undefined as any, done: true }; - return new Promise((resolve) => this.resolvers.push(resolve)); + + return new Promise((resolve, reject) => { + this.resolvers.push(resolve); + this.rejectors.push(reject); + }); } [Symbol.asyncIterator](): AsyncIterableIterator { @@ -44,20 +70,20 @@ export class ChunkedStream implements AsyncIterable { } export const mapStream = ( - fn: (chunk: T, i: number) => U | Promise, + fn: (chunk: T, index: number) => U | Promise, ) => async function* (source: AsyncIterable): AsyncIterable { - let i = 0; - for await (const chunk of source) yield await fn(chunk, i++); + let index = 0; + for await (const chunk of source) yield await fn(chunk, index++); }; export const filterStream = ( - pred: (chunk: T, i: number) => boolean | Promise, + pred: (chunk: T, index: number) => boolean | Promise, ) => async function* (source: AsyncIterable): AsyncIterable { - let i = 0; + let index = 0; for await (const chunk of source) { - if (await pred(chunk, i++)) yield chunk; + if (await pred(chunk, index++)) yield chunk; } }; @@ -72,9 +98,9 @@ export const takeStream = (count: number) => export const skipStream = (count: number) => async function* (source: AsyncIterable): AsyncIterable { - let i = 0; + let index = 0; for await (const chunk of source) { - if (i++ >= count) yield chunk; + if (index++ >= count) yield chunk; } }; @@ -88,17 +114,28 @@ export const batchStream = (size: number) => batch = []; } } - batch.length && (yield batch); + if (batch.length) yield batch; }; export const tapStream = ( - fn: (chunk: T, i: number) => void | Promise, + fn: (chunk: T, index: number) => void | Promise, ) => async function* (source: AsyncIterable): AsyncIterable { - let i = 0; + let index = 0; for await (const chunk of source) { yield chunk; - await fn(chunk, i++); + await fn(chunk, index++); + } + }; + +export const catchStream = ( + handler: (error: Error) => void | Promise, +) => + async function* (source: AsyncIterable): AsyncIterable { + try { + for await (const chunk of source) yield chunk; + } catch (err) { + await handler(err instanceof Error ? err : new Error(String(err))); } };