switch to precompilation-based jsx
This commit is contained in:
parent
4af7b21171
commit
884f773575
10 changed files with 179 additions and 450 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
}
|
||||
},
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "precompile",
|
||||
"jsxImportSource": "cobweb",
|
||||
"lib": ["deno.ns", "esnext", "dom", "dom.iterable"]
|
||||
}
|
||||
|
|
|
|||
34
example.tsx
34
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<Todo> {
|
|||
}
|
||||
}
|
||||
|
||||
const Layout = (props: { title: string; children: any }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{props.title}</title>
|
||||
<style>
|
||||
{`
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}`}
|
||||
</style>
|
||||
</head>
|
||||
<body>{props.children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
const TodoList = async function* (): AsyncGenerator<any> {
|
||||
yield <div class="loading">Loading todos...</div>;
|
||||
|
||||
|
|
@ -63,17 +42,16 @@ const TodoList = async function* (): AsyncGenerator<any> {
|
|||
const app = createRouter();
|
||||
|
||||
app.get("/", async (ctx) => {
|
||||
const { html } = ctx;
|
||||
const { stream: html } = ctx;
|
||||
|
||||
await render(
|
||||
<Layout title="Todo App">
|
||||
await (
|
||||
<>
|
||||
<h1>My Todos</h1>
|
||||
<Defer>
|
||||
<TodoList />
|
||||
</Defer>
|
||||
</Layout>,
|
||||
html.chunks,
|
||||
);
|
||||
</>
|
||||
)(html.chunks);
|
||||
|
||||
return html.response;
|
||||
});
|
||||
|
|
|
|||
8
src/global.d.ts
vendored
8
src/global.d.ts
vendored
|
|
@ -5,14 +5,13 @@
|
|||
|
||||
/// <reference types="cobweb/jsx-runtime" />
|
||||
|
||||
import type { JsxElement } from "cobweb/jsx-runtime";
|
||||
import type { Component } from "cobweb/jsx-runtime";
|
||||
|
||||
type HTMLAttributeMap<T = HTMLElement> = Partial<
|
||||
Omit<T, keyof Element | "children" | "style"> & {
|
||||
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<T = HTMLElement> = Partial<
|
|||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = JsxElement;
|
||||
export type ElementType =
|
||||
| keyof IntrinsicElements
|
||||
| Component<any>
|
||||
| ((props: any) => AsyncGenerator<any, any, any>);
|
||||
|
||||
export interface ElementChildrenAttribute {
|
||||
// deno-lint-ignore ban-types
|
||||
|
|
|
|||
140
src/html.ts
140
src/html.ts
|
|
@ -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<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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<void>;
|
||||
|
||||
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<string, any> =>
|
||||
arg &&
|
||||
typeof arg === "object" &&
|
||||
!isTemplateLiteral(arg) &&
|
||||
!Array.isArray(arg) &&
|
||||
!(arg instanceof Promise);
|
||||
|
||||
async function render(child: unknown): Promise<string> {
|
||||
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<string>): HtmlProxy {
|
||||
const cache = new Map<string, TagFn>();
|
||||
const write = (buf: string) => !chunks.closed && chunks.write(buf);
|
||||
|
||||
const handler: ProxyHandler<Record<string, TagFn>> = {
|
||||
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(`</${tag}>`);
|
||||
};
|
||||
|
||||
return (cache.set(tag, fn), fn);
|
||||
},
|
||||
};
|
||||
|
||||
const proxy = new Proxy({}, handler) as HtmlProxy;
|
||||
return proxy;
|
||||
}
|
||||
127
src/http.ts
127
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<string>
|
||||
| Promise<string>
|
||||
| Iterable<string>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
async function* normalize(
|
||||
value: Chunk | undefined | null,
|
||||
): AsyncIterable<string> {
|
||||
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<string>) {
|
||||
if (chunk != null) yield String(chunk);
|
||||
}
|
||||
} else {
|
||||
yield String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export type ChunkedWriter = (
|
||||
strings: TemplateStringsArray,
|
||||
...values: Chunk[]
|
||||
) => Promise<void>;
|
||||
|
||||
export const makeChunkWriter =
|
||||
(stream: ChunkedStream<string>): 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<string>();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
try {
|
||||
for await (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
return {
|
||||
chunks,
|
||||
stream: new ReadableStream<Uint8Array>({
|
||||
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 = "<!DOCTYPE html>";
|
||||
const HTML_BEGIN = (lang: string) =>
|
||||
`<html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">`;
|
||||
const HEAD_END = "</head><body";
|
||||
const BODY_END = ">";
|
||||
const HTML_END = "</body></html>";
|
||||
|
||||
export interface HtmlStream {
|
||||
write: ChunkedWriter;
|
||||
export interface DataStream {
|
||||
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 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<DataStream> {
|
||||
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",
|
||||
|
|
|
|||
290
src/jsx.ts
290
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<P = Props> = (props: P) => Promisable<Streamable<JsxElement>>;
|
||||
// deno-fmt-ignore
|
||||
const ESC_LUT: Record<string, string> = {
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
};
|
||||
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<P = Props> = (props: P) => JsxElement;
|
||||
|
||||
export type JsxElement = (chunks: ChunkedStream<string>) => Promise<void>;
|
||||
|
||||
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<Streamable<JsxChildBase | JsxElement>>;
|
||||
|
||||
export type JsxElement =
|
||||
| ((chunks: ChunkedStream<string>) => Promise<void>)
|
||||
| AsyncGenerator<any, void, unknown>;
|
||||
|
||||
const write = (chunks: ChunkedStream<string>, data: string) =>
|
||||
!chunks.closed && chunks.write(data);
|
||||
|
||||
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 === "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<JsxChild>) {
|
||||
await render(item, chunks, context);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
chunks.write(escape(String(child)));
|
||||
interface DeferProps {
|
||||
fallback?: JsxElement;
|
||||
children: JsxElement;
|
||||
}
|
||||
|
||||
export function jsx<P extends Props = Props>(
|
||||
tag: string | Component<P> | 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<string>, data: string) =>
|
||||
void (chunks && !chunks.closed && chunks.write(data));
|
||||
|
||||
async function render(
|
||||
node: any,
|
||||
chunks: ChunkedStream<string>,
|
||||
): Promise<void> {
|
||||
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<string>) => {
|
||||
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, `<div id="${id}">`);
|
||||
await render(fallback, chunks, context);
|
||||
write(chunks, `</div>`);
|
||||
|
||||
Promise.resolve(children).then(async (resolved) => {
|
||||
const buffer = new ChunkedStream<string>();
|
||||
await render(resolved, buffer, html(buffer));
|
||||
buffer.close();
|
||||
|
||||
const content: string[] = [];
|
||||
for await (const chunk of buffer) content.push(chunk);
|
||||
|
||||
write(chunks, `<div id="${id}">`);
|
||||
write(
|
||||
chunks,
|
||||
`<template shadowrootmode="open">${content.join("")}</template>`,
|
||||
);
|
||||
write(chunks, `</div>`);
|
||||
});
|
||||
|
||||
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<JsxElement>) {
|
||||
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, `</${tag}>`);
|
||||
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<P extends Props = Props>(
|
||||
tag: string | Component<P> | 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<string>,
|
||||
): Promise<void> {
|
||||
if (Array.isArray(element)) {
|
||||
for (const el of element) {
|
||||
await renderJsx(el, chunks);
|
||||
return async (chunks: ChunkedStream<string>) => {
|
||||
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, `</${tag}>`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const raw =
|
||||
(html: string): JsxElement => async (chunks: ChunkedStream<string>) =>
|
||||
void (!chunks.closed && chunks.write(html));
|
||||
async function defer(
|
||||
chunks: ChunkedStream<string>,
|
||||
{ fallback, children }: DeferProps,
|
||||
) {
|
||||
const id = `deferred-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
export const open = <K extends keyof HTMLElementTagNameMap>(tag: K) =>
|
||||
raw(`<${tag}>`);
|
||||
emit(chunks, `<div id="${id}">`);
|
||||
await render(fallback, chunks);
|
||||
emit(chunks, `</div>`);
|
||||
|
||||
export const close = <K extends keyof HTMLElementTagNameMap>(tag: K) =>
|
||||
raw(`</${tag}>`);
|
||||
Promise.resolve(children).then(async (resolved) => {
|
||||
const buffer = new ChunkedStream<string>();
|
||||
await render(resolved, buffer);
|
||||
buffer.close();
|
||||
|
||||
export { renderJsx as render };
|
||||
const content: string[] = [];
|
||||
for await (const chunk of buffer) content.push(chunk);
|
||||
|
||||
emit(
|
||||
chunks,
|
||||
`<div id="${id}"><template shadowrootmode="open">${
|
||||
content.join("")
|
||||
}</template></div>`,
|
||||
);
|
||||
}).catch((err) => {
|
||||
console.error("defer error:", err);
|
||||
emit(chunks, `<div>⚠️ something went wrong</div>`);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Params = Record<string, string>> {
|
||||
readonly request: Request;
|
||||
|
|
@ -12,7 +11,7 @@ export interface Context<Params = Record<string, string>> {
|
|||
readonly method: string;
|
||||
readonly params: Params;
|
||||
readonly pattern: URLPatternResult;
|
||||
readonly html: HtmlStream;
|
||||
readonly stream: DataStream;
|
||||
readonly signal: AbortSignal;
|
||||
state: Map<string | symbol, unknown>;
|
||||
}
|
||||
|
|
@ -20,7 +19,7 @@ export interface Context<Params = Record<string, string>> {
|
|||
export async function createContext<P = Record<string, string>>(
|
||||
request: Request,
|
||||
pattern: URLPatternResult,
|
||||
html: HtmlStream,
|
||||
stream: DataStream,
|
||||
): Promise<Context<P>> {
|
||||
return {
|
||||
request,
|
||||
|
|
@ -28,18 +27,18 @@ export async function createContext<P = Record<string, string>>(
|
|||
method: request.method,
|
||||
params: (pattern.pathname.groups || {}) as P,
|
||||
pattern,
|
||||
html,
|
||||
stream: stream,
|
||||
signal: request.signal,
|
||||
state: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface Handler<P = Record<string, string>> {
|
||||
(ctx: Context<P>): Promisable<Response | void>;
|
||||
(ctx: Context<P>): Promise<Response | void>;
|
||||
}
|
||||
|
||||
export interface Middleware {
|
||||
(ctx: Context, next: () => Promise<Response>): Promisable<Response>;
|
||||
(ctx: Context, next: () => Promise<Response>): Promise<Response>;
|
||||
}
|
||||
|
||||
export function compose(
|
||||
|
|
|
|||
|
|
@ -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)) ||
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 favewa
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
export type Promisable<T> = T | Promise<T>;
|
||||
|
||||
export type Streamable<T> = T | AsyncIterable<T>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue