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(`${tag}>`);
+
+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)));
}
};