better error handling and full jsx support

This commit is contained in:
laura 2025-11-01 17:21:09 -03:00
parent 11be5e979c
commit 5197e3316d
Signed by: w
GPG key ID: BCD2117C99E69817
7 changed files with 258 additions and 85 deletions

View file

@ -3,11 +3,24 @@
* SPDX-License-Identifier: AGPL-3.0-or-later * 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 <li>{fruit}</li>;
}
yield close("ol");
}
export default function App() { export default function App() {
return ( return (
<> <>
<h1>JSX Page</h1> <h1>JSX Page</h1>
<p class="oh hey">meowing chunk by chunk</p> <p class="oh hey">meowing chunk by chunk</p>
<Fruits />
</> </>
); );
} }

35
src/global.d.ts vendored
View file

@ -5,16 +5,37 @@
/// <reference types="interest/jsx-runtime" /> /// <reference types="interest/jsx-runtime" />
import type { ChunkedStream } from "./stream.ts"; import type { JsxElement } from "interest/jsx-runtime";
type HTMLAttributeMap<T = HTMLElement> = Partial<
Omit<T, keyof Element | "children" | "style"> & {
style?: string;
class?: string;
children?: any;
[key: `data-${string}`]: string | number | boolean | null | undefined;
[key: `aria-${string}`]: string | number | boolean | null | undefined;
}
>;
declare global { declare global {
namespace JSX { namespace JSX {
type Element = (chunks: ChunkedStream<string>) => Promise<void>; type Element = JsxElement;
interface IntrinsicElements {
[key: string]: ElementProps; export interface ElementChildrenAttribute {
} // deno-lint-ignore ban-types
interface ElementProps { children: {};
[key: string]: any;
} }
export type IntrinsicElements =
& {
[K in keyof HTMLElementTagNameMap]: HTMLAttributeMap<
HTMLElementTagNameMap[K]
>;
}
& {
[K in keyof SVGElementTagNameMap]: HTMLAttributeMap<
SVGElementTagNameMap[K]
>;
};
} }
} }

View file

@ -6,9 +6,12 @@
import { type Chunk } from "./http.ts"; import { type Chunk } from "./http.ts";
import { ChunkedStream } from "./stream.ts"; import { ChunkedStream } from "./stream.ts";
type Attrs = Record<string, string | number | boolean>; export type Attrs = Record<
string,
string | number | boolean | null | undefined
>;
const VOID_TAGS = new Set([ export const VOID_TAGS = new Set([
"area", "area",
"base", "base",
"br", "br",
@ -66,6 +69,7 @@ type TagRes = void | Promise<void>;
type TagFn = { type TagFn = {
(attrs: Attrs, ...children: Chunk[]): TagRes; (attrs: Attrs, ...children: Chunk[]): TagRes;
(attrs: Attrs, fn: () => any): TagRes;
(...children: Chunk[]): TagRes; (...children: Chunk[]): TagRes;
(template: TemplateStringsArray, ...values: Chunk[]): TagRes; (template: TemplateStringsArray, ...values: Chunk[]): TagRes;
(fn: () => any): TagRes; (fn: () => any): TagRes;
@ -73,30 +77,33 @@ type TagFn = {
export type HtmlProxy = { [K in keyof HTMLElementTagNameMap]: TagFn } & { export type HtmlProxy = { [K in keyof HTMLElementTagNameMap]: TagFn } & {
[key: string]: TagFn; [key: string]: TagFn;
} & { render(child: any): Promise<void> }; };
const isTemplateLiteral = (arg: any): arg is TemplateStringsArray => const isTemplateLiteral = (arg: any): arg is TemplateStringsArray =>
Array.isArray(arg) && "raw" in arg; Array.isArray(arg) && "raw" in arg;
const isAttributes = (arg: any): arg is Record<string, any> => const isAttributes = (arg: any): arg is Record<string, any> =>
arg && typeof arg === "object" && !isTemplateLiteral(arg); arg && typeof arg === "object" && !isTemplateLiteral(arg) &&
!Array.isArray(arg) && !(arg instanceof Promise);
async function render(child: any): Promise<string> { async function render(child: unknown): Promise<string> {
if (child == null) return ""; 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 (child instanceof Promise) return render(await child);
if (Array.isArray(child)) { if (Array.isArray(child)) {
return (await Promise.all(child.map(render))).join(""); return (await Promise.all(child.map(render))).join("");
} }
if (typeof child === "function") return render(await child()); if (typeof child === "function") return render(await child());
return String(child); return escape(String(child));
} }
export function html( export function html(chunks: ChunkedStream<string>): HtmlProxy {
chunks: ChunkedStream<string>,
): HtmlProxy {
const cache = new Map<string, TagFn>(); const cache = new Map<string, TagFn>();
const write = (buf: string) => !chunks.closed && chunks.write(buf); const write = (buf: string) => !chunks.closed && chunks.write(buf);
@ -105,11 +112,11 @@ export function html(
let fn = cache.get(tag); let fn = cache.get(tag);
if (fn) return fn; if (fn) return fn;
fn = async (...args: any[]) => { fn = async (...args: unknown[]) => {
const attrs = isAttributes(args[0]) ? args.shift() : undefined; const attrs = isAttributes(args[0]) ? args.shift() : undefined;
const isVoid = VOID_TAGS.has(tag.toLowerCase()); const isVoid = VOID_TAGS.has(tag.toLowerCase());
const attributes = serialize(attrs); const attributes = serialize(attrs as Attrs);
write(`<${tag}${attributes}${isVoid ? "/" : ""}>`); write(`<${tag}${attributes}${isVoid ? "/" : ""}>`);
if (isVoid) return; if (isVoid) return;
@ -126,7 +133,5 @@ export function html(
}; };
const proxy = new Proxy({}, handler) as HtmlProxy; const proxy = new Proxy({}, handler) as HtmlProxy;
proxy.render = async (stuff: any) => void write(await render(stuff));
return proxy; return proxy;
} }

View file

@ -15,7 +15,9 @@ export type Chunk =
| string | string
| AsyncIterable<string> | AsyncIterable<string>
| Promise<string> | Promise<string>
| Iterable<string>; | Iterable<string>
| null
| undefined;
async function* normalize( async function* normalize(
value: Chunk | undefined | null, value: Chunk | undefined | null,
@ -23,7 +25,6 @@ async function* normalize(
if (value == null) return; if (value == null) return;
if (typeof value === "string") { if (typeof value === "string") {
3;
yield value; yield value;
} else if (value instanceof Promise) { } else if (value instanceof Promise) {
const resolved = await value; const resolved = await value;
@ -31,7 +32,6 @@ async function* normalize(
} else if (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<string>) { for await (const chunk of value as AsyncIterable<string>) {
if (chunk != null) yield String(chunk); if (chunk != null) yield String(chunk);
1;
} }
} else { } else {
yield String(value); yield String(value);
@ -52,6 +52,7 @@ export const makeChunkWriter =
for (let i = 0; i < strings.length; i++) { for (let i = 0; i < strings.length; i++) {
strings[i] && emit(strings[i]); strings[i] && emit(strings[i]);
for await (const chunk of normalize(values[i])) { for await (const chunk of normalize(values[i])) {
emit(chunk); emit(chunk);
} }
@ -64,10 +65,14 @@ export function chunkedHtml() {
const stream = new ReadableStream<Uint8Array>({ const stream = new ReadableStream<Uint8Array>({
async start(controller) { async start(controller) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
for await (const chunk of chunks) { try {
controller.enqueue(encoder.encode(chunk)); for await (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
} catch (error) {
controller.error(error);
} }
controller.close();
}, },
cancel: chunks.close, cancel: chunks.close,
}); });
@ -82,7 +87,18 @@ const HEAD_END = "</head><body";
const BODY_END = ">"; const BODY_END = ">";
const HTML_END = "</body></html>"; const HTML_END = "</body></html>";
export async function createHtmlStream(options: StreamOptions = {}) { export interface HtmlStream {
write: ChunkedWriter;
blob: ReadableStream<Uint8Array>;
chunks: ChunkedStream<string>;
close(): void;
error(err: Error): void;
readonly response: Response;
}
export async function createHtmlStream(
options: StreamOptions = {},
): Promise<HtmlStream> {
const { chunks, stream } = chunkedHtml(); const { chunks, stream } = chunkedHtml();
const writer = makeChunkWriter(chunks); const writer = makeChunkWriter(chunks);
@ -103,15 +119,14 @@ export async function createHtmlStream(options: StreamOptions = {}) {
chunks.close(); chunks.close();
} }
}, },
get response(): Response { error: chunks.error,
return new Response(stream, { response: new Response(stream, {
headers: { headers: {
"Content-Type": "text/html; charset=utf-8", "Content-Type": "text/html; charset=utf-8",
"Transfer-Encoding": "chunked", "Transfer-Encoding": "chunked",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
}, },
}); }),
},
}; };
} }

View file

@ -3,27 +3,120 @@
* SPDX-License-Identifier: AGPL-3.0-or-later * 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"; import { ChunkedStream } from "./stream.ts";
let context;
export function jsx(
tag: string | typeof Fragment,
{ children }: Record<string, any> = {},
) {
return async (chunks: ChunkedStream<string>) => {
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"); export const Fragment = Symbol("Fragment");
type Props = Attrs & { children?: any };
type Component = (props: Props) => JsxElement | AsyncGenerator<JsxElement>;
export type JsxElement =
| ((chunks: ChunkedStream<string>) => Promise<void>)
| AsyncGenerator<JsxElement, void, unknown>;
async function render(
child: any,
chunks: ChunkedStream<string>,
context: ReturnType<typeof html>,
): Promise<void> {
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<string>) => {
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; export const jsxs = jsx;
async function renderJsx(
element: JsxElement | JsxElement[],
chunks: ChunkedStream<string>,
): Promise<void> {
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<string>) =>
void (!chunks.closed && chunks.write(html));
export const open = <K extends keyof HTMLElementTagNameMap>(tag: K) =>
raw(`<${tag}>`);
export const close = <K extends keyof HTMLElementTagNameMap>(tag: K) =>
raw(`</${tag}>`);
export { renderJsx as render };

View file

@ -3,26 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-License-Identifier: AGPL-3.0-or-later
*/ */
import { html } from "./html.ts";
import { createHtmlStream } from "./http.ts"; import { createHtmlStream } from "./http.ts";
import App from "./app.tsx"; import App from "./app.tsx";
import { render } from "interest/jsx-runtime";
Deno.serve({ Deno.serve({
port: 8080, port: 8080,
async handler() { async handler() {
const stream = await createHtmlStream({ lang: "en" }); const stream = await createHtmlStream({ lang: "en" });
const { ol, li } = html(stream.chunks); await render(App(), 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);
}
});
return stream.response; return stream.response;
}, },
}); });

View file

@ -5,7 +5,11 @@
export class ChunkedStream<T> implements AsyncIterable<T> { export class ChunkedStream<T> implements AsyncIterable<T> {
private readonly chunks: T[] = []; private readonly chunks: T[] = [];
private readonly resolvers: ((result: IteratorResult<T>) => void)[] = []; private readonly resolvers: ((result: IteratorResult<T>) => void)[] = [];
private readonly rejectors: ((error: Error) => void)[] = [];
private _error: Error | null = null;
private _closed = false; private _closed = false;
get closed(): boolean { get closed(): boolean {
@ -17,6 +21,7 @@ export class ChunkedStream<T> implements AsyncIterable<T> {
const resolver = this.resolvers.shift(); const resolver = this.resolvers.shift();
if (resolver) { if (resolver) {
this.rejectors.shift();
resolver({ value: chunk, done: false }); resolver({ value: chunk, done: false });
} else { } else {
this.chunks.push(chunk); this.chunks.push(chunk);
@ -26,16 +31,37 @@ export class ChunkedStream<T> implements AsyncIterable<T> {
close(): void { close(): void {
this._closed = true; this._closed = true;
while (this.resolvers.length) { while (this.resolvers.length) {
this.rejectors.shift();
this.resolvers.shift()!({ value: undefined! as any, done: true }); 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<IteratorResult<T>> { async next(): Promise<IteratorResult<T>> {
if (this._error) {
throw this._error;
}
if (this.chunks.length) { if (this.chunks.length) {
return { value: this.chunks.shift()!, done: false }; return { value: this.chunks.shift()!, done: false };
} }
if (this._closed) return { value: undefined as any, done: true }; 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<T> { [Symbol.asyncIterator](): AsyncIterableIterator<T> {
@ -44,20 +70,20 @@ export class ChunkedStream<T> implements AsyncIterable<T> {
} }
export const mapStream = <T, U>( export const mapStream = <T, U>(
fn: (chunk: T, i: number) => U | Promise<U>, fn: (chunk: T, index: number) => U | Promise<U>,
) => ) =>
async function* (source: AsyncIterable<T>): AsyncIterable<U> { async function* (source: AsyncIterable<T>): AsyncIterable<U> {
let i = 0; let index = 0;
for await (const chunk of source) yield await fn(chunk, i++); for await (const chunk of source) yield await fn(chunk, index++);
}; };
export const filterStream = <T>( export const filterStream = <T>(
pred: (chunk: T, i: number) => boolean | Promise<boolean>, pred: (chunk: T, index: number) => boolean | Promise<boolean>,
) => ) =>
async function* (source: AsyncIterable<T>): AsyncIterable<T> { async function* (source: AsyncIterable<T>): AsyncIterable<T> {
let i = 0; let index = 0;
for await (const chunk of source) { 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 = <T>(count: number) =>
export const skipStream = <T>(count: number) => export const skipStream = <T>(count: number) =>
async function* (source: AsyncIterable<T>): AsyncIterable<T> { async function* (source: AsyncIterable<T>): AsyncIterable<T> {
let i = 0; let index = 0;
for await (const chunk of source) { for await (const chunk of source) {
if (i++ >= count) yield chunk; if (index++ >= count) yield chunk;
} }
}; };
@ -88,17 +114,28 @@ export const batchStream = <T>(size: number) =>
batch = []; batch = [];
} }
} }
batch.length && (yield batch); if (batch.length) yield batch;
}; };
export const tapStream = <T>( export const tapStream = <T>(
fn: (chunk: T, i: number) => void | Promise<void>, fn: (chunk: T, index: number) => void | Promise<void>,
) => ) =>
async function* (source: AsyncIterable<T>): AsyncIterable<T> { async function* (source: AsyncIterable<T>): AsyncIterable<T> {
let i = 0; let index = 0;
for await (const chunk of source) { for await (const chunk of source) {
yield chunk; yield chunk;
await fn(chunk, i++); await fn(chunk, index++);
}
};
export const catchStream = <T>(
handler: (error: Error) => void | Promise<void>,
) =>
async function* (source: AsyncIterable<T>): AsyncIterable<T> {
try {
for await (const chunk of source) yield chunk;
} catch (err) {
await handler(err instanceof Error ? err : new Error(String(err)));
} }
}; };