diff --git a/README.md b/README.md index 930cd04..ad6f2b2 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,4 @@ applications - [ ] interactives structures and dynamic data visibility toggling via modern css features - [ ] only use html streaming shenanigans for noscript environments +- [ ] reactivity diff --git a/deno.json b/deno.json index 0c3d1be..cf3c634 100644 --- a/deno.json +++ b/deno.json @@ -17,7 +17,7 @@ } }, "compilerOptions": { - "jsx": "react-jsx", + "jsx": "precompile", "jsxImportSource": "cobweb", "lib": ["deno.ns", "esnext", "dom", "dom.iterable"] } diff --git a/example.tsx b/example.tsx index bb10cad..8a7819d 100644 --- a/example.tsx +++ b/example.tsx @@ -4,7 +4,7 @@ */ import { createRouter } from "cobweb/routing"; -import { Defer, render } from "cobweb/jsx-runtime"; +import { Defer } from "cobweb/jsx-runtime"; interface Todo { id: string; @@ -27,27 +27,6 @@ async function* fetchTodos(): AsyncGenerator { } } -const Layout = (props: { title: string; children: any }) => ( - - - - - {props.title} - - - {props.children} - -); - const TodoList = async function* (): AsyncGenerator { yield
Loading todos...
; @@ -63,17 +42,16 @@ const TodoList = async function* (): AsyncGenerator { const app = createRouter(); app.get("/", async (ctx) => { - const { html } = ctx; + const { stream: html } = ctx; - await render( - + await ( + <>

My Todos

-
, - html.chunks, - ); + + )(html.chunks); return html.response; }); diff --git a/src/global.d.ts b/src/global.d.ts index 0021bfa..91703a3 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -5,14 +5,13 @@ /// -import type { JsxElement } from "cobweb/jsx-runtime"; +import type { Component } from "cobweb/jsx-runtime"; type HTMLAttributeMap = Partial< Omit & { style?: string; class?: string; children?: any; - charset?: string; [key: `data-${string}`]: string | number | boolean | null | undefined; [key: `aria-${string}`]: string | number | boolean | null | undefined; } @@ -20,7 +19,10 @@ type HTMLAttributeMap = Partial< declare global { namespace JSX { - type Element = JsxElement; + export type ElementType = + | keyof IntrinsicElements + | Component + | ((props: any) => AsyncGenerator); export interface ElementChildrenAttribute { // deno-lint-ignore ban-types diff --git a/src/html.ts b/src/html.ts deleted file mode 100644 index 930d487..0000000 --- a/src/html.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Copyright (c) 2025 favewa - * SPDX-License-Identifier: BSD-3-Clause - */ - -import { type Chunk } from "./http.ts"; -import { ChunkedStream } from "./stream.ts"; - -export type Attrs = Record< - string, - string | number | boolean | null | undefined ->; - -export const VOID_TAGS = new Set([ - "area", - "base", - "br", - "col", - "embed", - "hr", - "img", - "input", - "link", - "meta", - "param", - "source", - "track", - "wbr", -]); - -const ESCAPE_MAP: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", -}; - -export function escape(input: string): string { - 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 serialize(attrs: Attrs | undefined): string { - if (!attrs) return ""; - 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 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; -}; - -export 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) && - !Array.isArray(arg) && - !(arg instanceof Promise); - -async function render(child: unknown): Promise { - if (child == null) return ""; - - 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 escape(String(child)); -} - -export function html(chunks: ChunkedStream): HtmlProxy { - const cache = new Map(); - const write = (buf: string) => !chunks.closed && chunks.write(buf); - - const handler: ProxyHandler> = { - get(_, tag: string) { - let fn = cache.get(tag); - if (fn) return fn; - - fn = async (...args: unknown[]) => { - const attrs = isAttributes(args[0]) ? args.shift() : undefined; - - const isVoid = VOID_TAGS.has(tag.toLowerCase()); - const attributes = serialize(attrs as Attrs); - - write(`<${tag}${attributes}${isVoid ? "/" : ""}>`); - if (isVoid) return; - - for (const child of args) { - write(await render(child)); - } - - write(``); - }; - - return (cache.set(tag, fn), fn); - }, - }; - - const proxy = new Proxy({}, handler) as HtmlProxy; - return proxy; -} diff --git a/src/http.ts b/src/http.ts index 0d65c4c..8cb1c50 100644 --- a/src/http.ts +++ b/src/http.ts @@ -6,123 +6,58 @@ import { ChunkedStream } from "./stream.ts"; export interface StreamOptions { - headContent?: string; - bodyAttributes?: string; - lang?: string; + contentType: 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() { +export function chunked() { const chunks = new ChunkedStream(); + const encoder = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - try { - for await (const chunk of chunks) { - controller.enqueue(encoder.encode(chunk)); + return { + chunks, + stream: new ReadableStream({ + async start(controller) { + try { + for await (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } catch (error) { + controller.error(error); } - controller.close(); - } catch (error) { - controller.error(error); - } - }, - cancel: chunks.close, - }); - - return { chunks, stream }; + }, + cancel: chunks.close, + }), + }; } -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); +export async function createDataStream( + options: StreamOptions = { + contentType: "text/html; charset=utf-8", + }, +): Promise { + const { chunks, stream } = chunked(); return { - write: writer, blob: stream, chunks, - close() { - if (!chunks.closed) { - chunks.write(HTML_END); - chunks.close(); - } - }, + close: chunks.close, error: chunks.error, response: new Response(stream, { headers: { - "Content-Type": "text/html; charset=utf-8", + "Content-Type": options.contentType, "Transfer-Encoding": "chunked", "Cache-Control": "no-cache", Connection: "keep-alive", diff --git a/src/jsx.ts b/src/jsx.ts index 35af369..85da75f 100644 --- a/src/jsx.ts +++ b/src/jsx.ts @@ -3,192 +3,154 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import { escape, html, VOID_TAGS } from "./html.ts"; import { ChunkedStream } from "./stream.ts"; -import type { Promisable, Streamable } from "./utils.ts"; -export const Fragment = Symbol("jsx.fragment") as any as ( - props: any, -) => JsxElement; -export const Defer = Symbol("jsx.async") as any as (props: any) => JsxElement; +// deno-fmt-ignore +export const voidTags = new Set([ + "area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr", +]); -type Component

= (props: P) => Promisable>; +// deno-fmt-ignore +const ESC_LUT: Record = { + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", +}; +const ESC_RE = /[&<>"']/g; -interface DeferProps { - fallback?: JsxChild; - children: JsxChild; -} +export const Fragment = Symbol("jsx.fragment") as any as JsxElement; +export const Defer = Symbol("jsx.defer") as any as Component; + +export type Component

= (props: P) => JsxElement; + +export type JsxElement = (chunks: ChunkedStream) => Promise; type Props = { - children?: JsxChild | JsxChild[]; + children?: JsxElement; key?: string | number; + [key: string]: unknown; }; -export type JsxChildBase = - | string - | number - | boolean - | null - | undefined; - -export type JsxChild = - | JsxElement - | JsxChildBase - | Promisable>; - -export type JsxElement = - | ((chunks: ChunkedStream) => Promise) - | AsyncGenerator; - -const write = (chunks: ChunkedStream, data: string) => - !chunks.closed && chunks.write(data); - -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 === "function") { - return await child(chunks, context); - } - if (child instanceof Promise) { - return await render(await child, chunks, context); - } - - if (Array.isArray(child)) { - for (const item of child) await render(item, chunks, context); - return; - } - - if (typeof child === "object" && Symbol.asyncIterator in child) { - for await (const item of child as AsyncIterable) { - await render(item, chunks, context); - } - return; - } - - chunks.write(escape(String(child))); +interface DeferProps { + fallback?: JsxElement; + children: JsxElement; } -export function jsx

( - tag: string | Component

| typeof Fragment | typeof Defer, - props: Props | null = {} as P, +export const jsxEscape = (input: string): string => + typeof input !== "string" ? input : input.replace(ESC_RE, (c) => ESC_LUT[c]); + +export const jsxAttr = (k: string, v: unknown) => + v == null || v === false + ? "" + : v === true + ? ` ${k}` + : ` ${k}="${jsxEscape(String(v))}"`; + +const emit = (chunks: ChunkedStream, data: string) => + void (chunks && !chunks.closed && chunks.write(data)); + +async function render( + node: any, + chunks: ChunkedStream, +): Promise { + if (node == null || typeof node === "boolean") return; + + if (typeof node === "string") return emit(chunks, node); + if (typeof node === "function") return node(chunks); + if (node instanceof Promise) return render(await node, chunks); + + if (Array.isArray(node)) { + for (const item of node) await render(item, chunks); + return; + } + if (typeof node === "object" && Symbol.asyncIterator in node) { + for await (const item of node) await render(item, chunks); + return; + } + + emit(chunks, escape(String(node))); +} + +export function jsxTemplate( + template: string[], + ...values: unknown[] ): JsxElement { - props ??= {} as P; - return async (chunks: ChunkedStream) => { - const context = html(chunks); - const { children, ...attrs } = props; - - if (tag === Fragment) { - for (const child of Array.isArray(children) ? children : [children]) { - await render(child, chunks, context); - } - return; - } - - if (tag === Defer) { - const { fallback = "", children } = props as DeferProps; - const id = `s${Math.random().toString(36).slice(2)}`; - - write(chunks, `

`); - await render(fallback, chunks, context); - write(chunks, `
`); - - Promise.resolve(children).then(async (resolved) => { - const buffer = new ChunkedStream(); - await render(resolved, buffer, html(buffer)); - buffer.close(); - - const content: string[] = []; - for await (const chunk of buffer) content.push(chunk); - - write(chunks, `
`); - write( - chunks, - ``, - ); - write(chunks, `
`); - }); - - return; - } - - if (typeof tag === "function") { - const result = await tag(props as P); - - if (typeof result === "object" && Symbol.asyncIterator in result) { - for await (const element of result as AsyncIterable) { - await render(element, chunks, context); - } - } else { - await render(result as JsxElement, chunks, context); - } - return; - } - - const kids = children == null ? [] : [children]; - const isVoid = VOID_TAGS.has(tag); - - if (!Object.keys(attrs).length && (!kids.length || isVoid)) { - return await context[tag](); - } - write(chunks, `<${tag}`); - - for (const key in attrs) { - const val = (attrs as any)[key]; - val && write( - chunks, - val === true ? ` ${key}` : ` ${key}="${escape(String(val))}"`, - ); - } - write(chunks, isVoid ? "/>" : ">"); - - if (!isVoid) { - for (const child of kids) { - await render(child, chunks, context); - } - write(chunks, ``); + for (let i = 0; i < template.length; i++) { + emit(chunks, template[i]); + i < values.length && await render(values[i], chunks); } }; } -export const jsxs = jsx; +export function jsx

( + tag: string | Component

| typeof Fragment | typeof Defer, + props: P | null = {} as P, + key?: string | number, +): JsxElement { + props ??= {} as P; + if (key !== undefined) props.key = key; -async function renderJsx( - element: JsxElement | JsxElement[], - chunks: ChunkedStream, -): Promise { - if (Array.isArray(element)) { - for (const el of element) { - await renderJsx(el, chunks); + return async (chunks: ChunkedStream) => { + const { children, key: _, ...attrs } = props; + + if (tag === Fragment) { + for (const child of Array.isArray(children) ? children : [children]) { + await render(child, chunks); + return; + } } - return; - } - if (typeof element === "object" && Symbol.asyncIterator in element) { - for await (const item of element) { - await renderJsx(item, chunks); + + if (tag === Defer) { + return defer(chunks, props as DeferProps); } - return; - } - if (typeof element === "function") { - await element(chunks); - } + + if (typeof tag === "function") { + const result = await (tag as any)(props); + return render(result, chunks); + } + + const isVoid = voidTags.has(tag); + + emit(chunks, `<${tag}`); + for (const name in attrs) { + const value = (attrs as any)[name]; + emit(chunks, jsxAttr(name, value)); + } + emit(chunks, isVoid ? "/>" : ">"); + + if (!isVoid) { + await render(children, chunks); + emit(chunks, ``); + } + }; } -export const raw = - (html: string): JsxElement => async (chunks: ChunkedStream) => - void (!chunks.closed && chunks.write(html)); +async function defer( + chunks: ChunkedStream, + { fallback, children }: DeferProps, +) { + const id = `deferred-${Math.random().toString(36).slice(2, 10)}`; -export const open = (tag: K) => - raw(`<${tag}>`); + emit(chunks, `

`); + await render(fallback, chunks); + emit(chunks, `
`); -export const close = (tag: K) => - raw(``); + Promise.resolve(children).then(async (resolved) => { + const buffer = new ChunkedStream(); + await render(resolved, buffer); + buffer.close(); -export { renderJsx as render }; + const content: string[] = []; + for await (const chunk of buffer) content.push(chunk); + + emit( + chunks, + `
`, + ); + }).catch((err) => { + console.error("defer error:", err); + emit(chunks, `
⚠️ something went wrong
`); + }); +} diff --git a/src/middleware.ts b/src/middleware.ts index 36ad5fd..363b326 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,8 +3,7 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import { HtmlStream } from "./http.ts"; -import { Promisable } from "./utils.ts"; +import { DataStream } from "./http.ts"; export interface Context> { readonly request: Request; @@ -12,7 +11,7 @@ export interface Context> { readonly method: string; readonly params: Params; readonly pattern: URLPatternResult; - readonly html: HtmlStream; + readonly stream: DataStream; readonly signal: AbortSignal; state: Map; } @@ -20,7 +19,7 @@ export interface Context> { export async function createContext

>( request: Request, pattern: URLPatternResult, - html: HtmlStream, + stream: DataStream, ): Promise> { return { request, @@ -28,18 +27,18 @@ export async function createContext

>( method: request.method, params: (pattern.pathname.groups || {}) as P, pattern, - html, + stream: stream, signal: request.signal, state: new Map(), }; } export interface Handler

> { - (ctx: Context

): Promisable; + (ctx: Context

): Promise; } export interface Middleware { - (ctx: Context, next: () => Promise): Promisable; + (ctx: Context, next: () => Promise): Promise; } export function compose( diff --git a/src/router.ts b/src/router.ts index d6457af..f95c99f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import { createHtmlStream } from "./http.ts"; +import { createDataStream } from "./http.ts"; import { compose, createContext, Handler, Middleware } from "./middleware.ts"; // why is Request["method"] a bare `string` oh my lord kill me @@ -117,8 +117,8 @@ export function createRouter(namespace?: string): Router { const match = route.pattern.exec(request.url); if (!match) continue; - const html = await createHtmlStream(); - const ctx = await createContext(request, match, html); + const stream = await createDataStream(); + const ctx = await createContext(request, match, stream); return ( (await compose(middlewares, route.handler)(ctx)) || diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 99def07..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Copyright (c) 2025 favewa - * SPDX-License-Identifier: BSD-3-Clause - */ - -export type Promisable = T | Promise; - -export type Streamable = T | AsyncIterable;