implementing routing and streamline tsx runtime

This commit is contained in:
laura 2025-11-08 04:52:45 -03:00
parent 3d733cfe0b
commit 4af7b21171
Signed by: w
GPG key ID: BCD2117C99E69817
14 changed files with 820 additions and 451 deletions

View file

@ -1,3 +1,18 @@
# 🕸️ cobweb # 🕸️ cobweb
a lightweight, tiny web framework for deno a lightweight, tiny web framework for deno designed for dynamic no-js
applications
# status
- [x] type-safe routing
- [x] html streaming support
- [x] jsx runtime
- [ ] intutive high-level apis
- [ ] safely defer html streams
- [ ] isolated deferred rendering through iframes
- [ ] scoped css through shadow dom
- [ ] css-in-js library
- [ ] interactives structures and dynamic data visibility toggling via modern
css features
- [ ] only use html streaming shenanigans for noscript environments

View file

@ -1,21 +1,24 @@
{ {
"tasks": {}, "tasks": {},
"imports": { "imports": {
"cobweb/jsx-runtime": "./src/jsx.ts" "cobweb/": "./src/",
}, "cobweb/routing": "./src/router.ts",
"fmt": { "cobweb/helpers": "./src/helpers.ts",
"useTabs": true, "cobweb/jsx-runtime": "./src/jsx.ts"
"semiColons": true },
}, "fmt": {
"lint": { "useTabs": true,
"rules": { "semiColons": true
"tags": ["recommended"], },
"exclude": ["no-explicit-any", "require-await"] "lint": {
} "rules": {
}, "tags": ["recommended"],
"compilerOptions": { "exclude": ["no-explicit-any", "require-await"]
"jsx": "react-jsx", }
"jsxImportSource": "cobweb", },
"lib": ["deno.ns", "esnext", "dom", "dom.iterable"] "compilerOptions": {
} "jsx": "react-jsx",
"jsxImportSource": "cobweb",
"lib": ["deno.ns", "esnext", "dom", "dom.iterable"]
}
} }

85
example.tsx Normal file
View file

@ -0,0 +1,85 @@
/**
* Copyright (c) 2025 favewa
* SPDX-License-Identifier: BSD-3-Clause
*/
import { createRouter } from "cobweb/routing";
import { Defer, render } from "cobweb/jsx-runtime";
interface Todo {
id: string;
text: string;
done: boolean;
createdAt: Date;
}
const todos: Todo[] = [
{ id: "1", text: "meow", done: true, createdAt: new Date() },
{ id: "2", text: "mrrp", done: false, createdAt: new Date() },
{ id: "3", text: "mrrp", done: false, createdAt: new Date() },
{ id: "4", text: "mrrp", done: false, createdAt: new Date() },
];
async function* fetchTodos(): AsyncGenerator<Todo> {
for (const todo of todos) {
await new Promise((r) => setTimeout(r, 300));
yield 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>;
for await (const todo of fetchTodos()) {
yield (
<div class={`todo ${todo.done ? "done" : ""}`}>
<span class="text">{todo.text}</span>
</div>
);
}
};
const app = createRouter();
app.get("/", async (ctx) => {
const { html } = ctx;
await render(
<Layout title="Todo App">
<h1>My Todos</h1>
<Defer>
<TodoList />
</Defer>
</Layout>,
html.chunks,
);
return html.response;
});
app.get("/meow/:test?", async (ctx) => {
console.log(ctx.params.test);
});
Deno.serve({ port: 8000 }, app.fetch);

View file

@ -14,16 +14,18 @@ const copyrightHeader = `/**
const dir = "./"; const dir = "./";
for await (const entry of walk(dir, { for await (
exts: [".ts", ".tsx"], const entry of walk(dir, {
includeDirs: false, exts: [".ts", ".tsx"],
skip: [/node_modules/, /copyright\.ts$/], includeDirs: false,
})) { skip: [/node_modules/, /copyright\.ts$/],
const filePath = entry.path; })
const content = await Deno.readTextFile(filePath); ) {
const filePath = entry.path;
const content = await Deno.readTextFile(filePath);
if (!content.startsWith(copyrightHeader)) { if (!content.startsWith(copyrightHeader)) {
await Deno.writeTextFile(filePath, copyrightHeader + "\n" + content); await Deno.writeTextFile(filePath, copyrightHeader + "\n" + content);
console.log(`Added header to ${filePath}`); console.log(`Added header to ${filePath}`);
} }
} }

49
src/global.d.ts vendored
View file

@ -8,32 +8,35 @@
import type { JsxElement } from "cobweb/jsx-runtime"; import type { JsxElement } from "cobweb/jsx-runtime";
type HTMLAttributeMap<T = HTMLElement> = Partial< type HTMLAttributeMap<T = HTMLElement> = Partial<
Omit<T, keyof Element | "children" | "style"> & { Omit<T, keyof Element | "children" | "style"> & {
style?: string; style?: string;
class?: string; class?: string;
children?: any; children?: any;
[key: `data-${string}`]: string | number | boolean | null | undefined; charset?: string;
[key: `aria-${string}`]: string | number | boolean | null | undefined; [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 = JsxElement; type Element = JsxElement;
export interface ElementChildrenAttribute { export interface ElementChildrenAttribute {
// deno-lint-ignore ban-types // deno-lint-ignore ban-types
children: {}; children: {};
} }
export type IntrinsicElements = { export type IntrinsicElements =
[K in keyof HTMLElementTagNameMap]: HTMLAttributeMap< & {
HTMLElementTagNameMap[K] [K in keyof HTMLElementTagNameMap]: HTMLAttributeMap<
>; HTMLElementTagNameMap[K]
} & { >;
[K in keyof SVGElementTagNameMap]: HTMLAttributeMap< }
SVGElementTagNameMap[K] & {
>; [K in keyof SVGElementTagNameMap]: HTMLAttributeMap<
}; SVGElementTagNameMap[K]
} >;
};
}
} }

46
src/helpers.ts Normal file
View file

@ -0,0 +1,46 @@
/**
* Copyright (c) 2025 favewa
* SPDX-License-Identifier: BSD-3-Clause
*/
export function json(data: unknown, init?: ResponseInit): Response {
const headers = new Headers({
"Content-Type": "application/json",
...init?.headers,
});
return new Response(JSON.stringify(data), { ...init, headers });
}
export function text(body: string, init?: ResponseInit): Response {
const headers = new Headers({
"Content-Type": "text/plain",
...init?.headers,
});
return new Response(body, { ...init, headers });
}
export function html(body: string, init?: ResponseInit): Response {
const headers = new Headers({
"Content-Type": "text/html; charset=utf-8",
...init?.headers,
});
return new Response(body, { ...init, headers });
}
export function redirect(url: string | URL, status = 302): Response {
const headers = new Headers({ Location: url.toString() });
return new Response(null, { status, headers });
}
export function stream(
body: ReadableStream<Uint8Array>,
init?: ResponseInit,
): Response {
const headers = new Headers({
"Content-Type": "text/html; charset=utf-8",
"Transfer-Encoding": "chunked",
"Cache-Control": "no-cache",
...init?.headers,
});
return new Response(body, { ...init, headers });
}

View file

@ -7,134 +7,134 @@ import { type Chunk } from "./http.ts";
import { ChunkedStream } from "./stream.ts"; import { ChunkedStream } from "./stream.ts";
export type Attrs = Record< export type Attrs = Record<
string, string,
string | number | boolean | null | undefined string | number | boolean | null | undefined
>; >;
export const VOID_TAGS = new Set([ export const VOID_TAGS = new Set([
"area", "area",
"base", "base",
"br", "br",
"col", "col",
"embed", "embed",
"hr", "hr",
"img", "img",
"input", "input",
"link", "link",
"meta", "meta",
"param", "param",
"source", "source",
"track", "track",
"wbr", "wbr",
]); ]);
const ESCAPE_MAP: Record<string, string> = { const ESCAPE_MAP: Record<string, string> = {
"&": "&amp;", "&": "&amp;",
"<": "&lt;", "<": "&lt;",
">": "&gt;", ">": "&gt;",
'"': "&quot;", '"': "&quot;",
"'": "&#039;", "'": "&#039;",
}; };
export function escape(input: string): string { export function escape(input: string): string {
let result = ""; let result = "";
let lastIndex = 0; let lastIndex = 0;
for (let i = 0; i < input.length; i++) { for (let i = 0; i < input.length; i++) {
const replacement = ESCAPE_MAP[input[i]]; const replacement = ESCAPE_MAP[input[i]];
if (replacement) { if (replacement) {
result += input.slice(lastIndex, i) + replacement; result += input.slice(lastIndex, i) + replacement;
lastIndex = i + 1; lastIndex = i + 1;
} }
} }
return lastIndex ? result + input.slice(lastIndex) : input; return lastIndex ? result + input.slice(lastIndex) : input;
} }
function serialize(attrs: Attrs | undefined): string { function serialize(attrs: Attrs | undefined): string {
if (!attrs) return ""; if (!attrs) return "";
let output = ""; let output = "";
for (const key in attrs) { for (const key in attrs) {
const val = attrs[key]; const val = attrs[key];
if (val == null || val === false) continue; if (val == null || val === false) continue;
output += " "; output += " ";
output += val === true ? key : `${key}="${escape(String(val))}"`; output += val === true ? key : `${key}="${escape(String(val))}"`;
} }
return output; return output;
} }
type TagRes = void | Promise<void>; type TagRes = void | Promise<void>;
type TagFn = { type TagFn = {
(attrs: Attrs, ...children: Chunk[]): TagRes; (attrs: Attrs, ...children: Chunk[]): TagRes;
(attrs: Attrs, fn: () => any): 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;
}; };
export type HtmlProxy = { [K in keyof HTMLElementTagNameMap]: TagFn } & { export type HtmlProxy = { [K in keyof HTMLElementTagNameMap]: TagFn } & {
[key: string]: TagFn; [key: string]: TagFn;
}; };
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 && arg &&
typeof arg === "object" && typeof arg === "object" &&
!isTemplateLiteral(arg) && !isTemplateLiteral(arg) &&
!Array.isArray(arg) && !Array.isArray(arg) &&
!(arg instanceof Promise); !(arg instanceof Promise);
async function render(child: unknown): Promise<string> { async function render(child: unknown): Promise<string> {
if (child == null) return ""; if (child == null) return "";
if (typeof child === "string") return escape(child); if (typeof child === "string") return escape(child);
if (typeof child === "number") return String(child); if (typeof child === "number") return String(child);
if (typeof child === "boolean") return String(Number(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 escape(String(child)); return escape(String(child));
} }
export function html(chunks: ChunkedStream<string>): HtmlProxy { export function html(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);
const handler: ProxyHandler<Record<string, TagFn>> = { const handler: ProxyHandler<Record<string, TagFn>> = {
get(_, tag: string) { get(_, tag: string) {
let fn = cache.get(tag); let fn = cache.get(tag);
if (fn) return fn; if (fn) return fn;
fn = async (...args: unknown[]) => { 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 as Attrs); const attributes = serialize(attrs as Attrs);
write(`<${tag}${attributes}${isVoid ? "/" : ""}>`); write(`<${tag}${attributes}${isVoid ? "/" : ""}>`);
if (isVoid) return; if (isVoid) return;
for (const child of args) { for (const child of args) {
write(await render(child)); write(await render(child));
} }
write(`</${tag}>`); write(`</${tag}>`);
}; };
return (cache.set(tag, fn), fn); return (cache.set(tag, fn), fn);
}, },
}; };
const proxy = new Proxy({}, handler) as HtmlProxy; const proxy = new Proxy({}, handler) as HtmlProxy;
return proxy; return proxy;
} }

View file

@ -6,127 +6,127 @@
import { ChunkedStream } from "./stream.ts"; import { ChunkedStream } from "./stream.ts";
export interface StreamOptions { export interface StreamOptions {
headContent?: string; headContent?: string;
bodyAttributes?: string; bodyAttributes?: string;
lang?: string; lang?: string;
} }
export type Chunk = export type Chunk =
| string | string
| AsyncIterable<string> | AsyncIterable<string>
| Promise<string> | Promise<string>
| Iterable<string> | Iterable<string>
| null | null
| undefined; | undefined;
async function* normalize( async function* normalize(
value: Chunk | undefined | null, value: Chunk | undefined | null,
): AsyncIterable<string> { ): AsyncIterable<string> {
if (value == null) return; if (value == null) return;
if (typeof value === "string") { if (typeof value === "string") {
yield value; yield value;
} else if (value instanceof Promise) { } else if (value instanceof Promise) {
const resolved = await value; const resolved = await value;
if (resolved != null) yield String(resolved); if (resolved != null) yield String(resolved);
} 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);
} }
} else { } else {
yield String(value); yield String(value);
} }
} }
export type ChunkedWriter = ( export type ChunkedWriter = (
strings: TemplateStringsArray, strings: TemplateStringsArray,
...values: Chunk[] ...values: Chunk[]
) => Promise<void>; ) => Promise<void>;
export const makeChunkWriter = export const makeChunkWriter =
(stream: ChunkedStream<string>): ChunkedWriter => (stream: ChunkedStream<string>): ChunkedWriter =>
async (strings, ...values) => { async (strings, ...values) => {
const emit = (chunk: string) => const emit = (chunk: string) =>
!stream.closed && !stream.closed &&
(chunk === "EOF" ? stream.close() : stream.write(chunk)); (chunk === "EOF" ? stream.close() : stream.write(chunk));
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);
} }
} }
}; };
export function chunkedHtml() { export function chunkedHtml() {
const chunks = new ChunkedStream<string>(); const chunks = new ChunkedStream<string>();
const stream = new ReadableStream<Uint8Array>({ const stream = new ReadableStream<Uint8Array>({
async start(controller) { async start(controller) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
try { try {
for await (const chunk of chunks) { for await (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk)); controller.enqueue(encoder.encode(chunk));
} }
controller.close(); controller.close();
} catch (error) { } catch (error) {
controller.error(error); controller.error(error);
} }
}, },
cancel: chunks.close, cancel: chunks.close,
}); });
return { chunks, stream }; return { chunks, stream };
} }
const DOCUMENT_TYPE = "<!DOCTYPE html>"; const DOCUMENT_TYPE = "<!DOCTYPE html>";
const HTML_BEGIN = (lang: string) => const HTML_BEGIN = (lang: string) =>
`<html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">`; `<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 HEAD_END = "</head><body";
const BODY_END = ">"; const BODY_END = ">";
const HTML_END = "</body></html>"; const HTML_END = "</body></html>";
export interface HtmlStream { export interface HtmlStream {
write: ChunkedWriter; write: ChunkedWriter;
blob: ReadableStream<Uint8Array>; blob: ReadableStream<Uint8Array>;
chunks: ChunkedStream<string>; chunks: ChunkedStream<string>;
close(): void; close(): void;
error(err: Error): void; error(err: Error): void;
readonly response: Response; readonly response: Response;
} }
export async function createHtmlStream( export async function createHtmlStream(
options: StreamOptions = {}, options: StreamOptions = {},
): Promise<HtmlStream> { ): Promise<HtmlStream> {
const { chunks, stream } = chunkedHtml(); const { chunks, stream } = chunkedHtml();
const writer = makeChunkWriter(chunks); const writer = makeChunkWriter(chunks);
chunks.write(DOCUMENT_TYPE); chunks.write(DOCUMENT_TYPE);
chunks.write(HTML_BEGIN(options.lang || "en")); chunks.write(HTML_BEGIN(options.lang || "en"));
options.headContent && chunks.write(options.headContent); options.headContent && chunks.write(options.headContent);
chunks.write(HEAD_END); chunks.write(HEAD_END);
options.bodyAttributes && chunks.write(" " + options.bodyAttributes); options.bodyAttributes && chunks.write(" " + options.bodyAttributes);
chunks.write(BODY_END); chunks.write(BODY_END);
return { return {
write: writer, write: writer,
blob: stream, blob: stream,
chunks, chunks,
close() { close() {
if (!chunks.closed) { if (!chunks.closed) {
chunks.write(HTML_END); chunks.write(HTML_END);
chunks.close(); chunks.close();
} }
}, },
error: chunks.error, error: chunks.error,
response: 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

@ -1,8 +0,0 @@
/**
* Copyright (c) 2025 favewa
* SPDX-License-Identifier: BSD-3-Clause
*/
export * from "./html.ts";
export * from "./http.ts";
export * from "./jsx.ts";

View file

@ -3,121 +3,192 @@
* SPDX-License-Identifier: BSD-3-Clause * SPDX-License-Identifier: BSD-3-Clause
*/ */
import { Attrs, escape, html, VOID_TAGS } from "./html.ts"; import { escape, html, VOID_TAGS } from "./html.ts";
import { ChunkedStream } from "./stream.ts"; import { ChunkedStream } from "./stream.ts";
import type { Promisable, Streamable } from "./utils.ts";
export const Fragment = Symbol("Fragment"); export const Fragment = Symbol("jsx.fragment") as any as (
props: any,
) => JsxElement;
export const Defer = Symbol("jsx.async") as any as (props: any) => JsxElement;
type Props = Attrs & { children?: any }; type Component<P = Props> = (props: P) => Promisable<Streamable<JsxElement>>;
type Component = (props: Props) => JsxElement | AsyncGenerator<JsxElement>;
export type JsxElement = interface DeferProps {
| ((chunks: ChunkedStream<string>) => Promise<void>) fallback?: JsxChild;
| AsyncGenerator<JsxElement, void, unknown>; children: JsxChild;
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( type Props = {
tag: string | Component | typeof Fragment, children?: JsxChild | JsxChild[];
props: Props | null = {}, key?: string | number;
};
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)));
}
export function jsx<P extends Props = Props>(
tag: string | Component<P> | typeof Fragment | typeof Defer,
props: Props | null = {} as P,
): JsxElement { ): JsxElement {
props ||= {}; props ??= {} as P;
return async (chunks: ChunkedStream<string>) => { return async (chunks: ChunkedStream<string>) => {
const context = html(chunks); const context = html(chunks);
const { children, ...attrs } = props; const { children, ...attrs } = props;
if (tag === Fragment) { if (tag === Fragment) {
if (!Array.isArray(children)) { for (const child of Array.isArray(children) ? children : [children]) {
return await render([children], chunks, context); await render(child, chunks, context);
} }
for (const child of children) { return;
await render(child, chunks, context); }
}
return;
}
if (typeof tag === "function") { if (tag === Defer) {
return await render(tag(props), chunks, context); const { fallback = "", children } = props as DeferProps;
} const id = `s${Math.random().toString(36).slice(2)}`;
const childr = children == null ? [] : [].concat(children); write(chunks, `<div id="${id}">`);
const attributes = Object.keys(attrs).length ? attrs : {}; await render(fallback, chunks, context);
write(chunks, `</div>`);
if (!childr.length || VOID_TAGS.has(tag)) { Promise.resolve(children).then(async (resolved) => {
await context[tag](childr); const buffer = new ChunkedStream<string>();
} else { await render(resolved, buffer, html(buffer));
await context[tag](attributes, async () => { buffer.close();
for (const child of childr) {
await render(child, chunks, context); 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}>`);
}
};
} }
export const jsxs = jsx; export const jsxs = jsx;
async function renderJsx( async function renderJsx(
element: JsxElement | JsxElement[], element: JsxElement | JsxElement[],
chunks: ChunkedStream<string>, chunks: ChunkedStream<string>,
): Promise<void> { ): Promise<void> {
if (Array.isArray(element)) { if (Array.isArray(element)) {
for (const el of element) { for (const el of element) {
await renderJsx(el, chunks); await renderJsx(el, chunks);
} }
return; return;
} }
if (typeof element === "object" && Symbol.asyncIterator in element) { if (typeof element === "object" && Symbol.asyncIterator in element) {
for await (const item of element) { for await (const item of element) {
await renderJsx(item, chunks); await renderJsx(item, chunks);
} }
return; return;
} }
if (typeof element === "function") { if (typeof element === "function") {
await element(chunks); await element(chunks);
} }
} }
export const raw = export const raw =
(html: string): JsxElement => (html: string): JsxElement => async (chunks: ChunkedStream<string>) =>
async (chunks: ChunkedStream<string>) => void (!chunks.closed && chunks.write(html));
void (!chunks.closed && chunks.write(html));
export const open = <K extends keyof HTMLElementTagNameMap>(tag: K) => export const open = <K extends keyof HTMLElementTagNameMap>(tag: K) =>
raw(`<${tag}>`); raw(`<${tag}>`);
export const close = <K extends keyof HTMLElementTagNameMap>(tag: K) => export const close = <K extends keyof HTMLElementTagNameMap>(tag: K) =>
raw(`</${tag}>`); raw(`</${tag}>`);
export { renderJsx as render }; export { renderJsx as render };

68
src/middleware.ts Normal file
View file

@ -0,0 +1,68 @@
/**
* Copyright (c) 2025 favewa
* SPDX-License-Identifier: BSD-3-Clause
*/
import { HtmlStream } from "./http.ts";
import { Promisable } from "./utils.ts";
export interface Context<Params = Record<string, string>> {
readonly request: Request;
readonly url: URL;
readonly method: string;
readonly params: Params;
readonly pattern: URLPatternResult;
readonly html: HtmlStream;
readonly signal: AbortSignal;
state: Map<string | symbol, unknown>;
}
export async function createContext<P = Record<string, string>>(
request: Request,
pattern: URLPatternResult,
html: HtmlStream,
): Promise<Context<P>> {
return {
request,
url: new URL(request.url),
method: request.method,
params: (pattern.pathname.groups || {}) as P,
pattern,
html,
signal: request.signal,
state: new Map(),
};
}
export interface Handler<P = Record<string, string>> {
(ctx: Context<P>): Promisable<Response | void>;
}
export interface Middleware {
(ctx: Context, next: () => Promise<Response>): Promisable<Response>;
}
export function compose(
middlewares: readonly Middleware[],
handler: Handler,
): Handler {
if (!middlewares.length) return handler;
return (ctx) => {
let index = -1;
async function dispatch(i: number): Promise<Response> {
if (i <= index) throw new Error("next() called multiple times");
index = i;
const fn = i < middlewares.length ? middlewares[i] : handler;
if (!fn) throw new Error("No handler found");
const result = await fn(ctx, () => dispatch(i + 1));
if (!result) throw new Error("Handler must return Response");
return result;
}
return dispatch(0);
};
}

151
src/router.ts Normal file
View file

@ -0,0 +1,151 @@
/**
* Copyright (c) 2025 favewa
* SPDX-License-Identifier: BSD-3-Clause
*/
import { createHtmlStream } from "./http.ts";
import { compose, createContext, Handler, Middleware } from "./middleware.ts";
// why is Request["method"] a bare `string` oh my lord kill me
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
type ExtractParameterNames<S extends string> = S extends
`${string}:${infer Param}/${infer Rest}`
? Param | ExtractParameterNames<`/${Rest}`>
: S extends `${string}:${infer Param}` ? Param
: never;
type Skippable<S extends string, T> = S extends `${string}?` ? T | undefined
: T;
type StripOptional<S extends string> = S extends `${infer P}?` ? P : S;
export type ParametersOf<S extends string> = {
[K in ExtractParameterNames<S> as StripOptional<K>]: Skippable<
K,
string
>;
};
export interface TypedURLPattern<S extends string> extends URLPattern {
readonly raw: S;
}
export function url<const S extends string>(
init: URLPatternInit & { pathname: S },
): TypedURLPattern<S> {
const pattern = new URLPattern(init) as TypedURLPattern<S>;
return (((pattern as any).raw = init.pathname), pattern);
}
export interface Route {
readonly pattern: URLPattern;
handler: Handler;
method: Method;
}
type HandlerParams<P> = P extends TypedURLPattern<any> ? ParametersOf<P["raw"]>
: P extends string ? ParametersOf<P>
: P extends URLPattern ? Record<string, string>
: never;
interface BaseRouter {
routes: Route[];
middlewares: Middleware[];
namespace?: string;
use: (...middlewares: Middleware[]) => this;
on<P extends string | TypedURLPattern<any> | URLPattern>(
method: Method,
path: P,
handler: Handler<HandlerParams<P>>,
): P;
fetch: (request: Request) => Promise<Response>;
}
type Router =
& BaseRouter
& {
[M in Method as Lowercase<M>]: <
P extends string | TypedURLPattern<any> | URLPattern,
>(
path: P,
handler: Handler<HandlerParams<P>>,
) => Router;
};
export function createRouter(namespace?: string): Router {
const routes: Route[] = [];
const middlewares: Middleware[] = [];
const router: BaseRouter = {
routes,
middlewares,
namespace,
use(...mw: Middleware[]) {
middlewares.push(...mw);
return router;
},
on<P extends string | TypedURLPattern<any> | URLPattern>(
method: Method,
path: P,
handler: Handler<HandlerParams<P>>,
): P {
const pattern: URLPattern = typeof path === "string"
? url({ pathname: path })
: (path as URLPattern);
routes.push({
method,
pattern,
handler: handler as Handler,
});
return path;
},
async fetch(request: Request): Promise<Response> {
const method = request.method.toUpperCase() as Method;
for (const route of routes) {
if (route.method !== method) continue;
const match = route.pattern.exec(request.url);
if (!match) continue;
const html = await createHtmlStream();
const ctx = await createContext(request, match, html);
return (
(await compose(middlewares, route.handler)(ctx)) ||
new Response("", { status: 200 })
);
}
return new Response("Not Found", {
status: 404,
headers: new Headers({ "Content-Type": "text/plain" }),
});
},
};
["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].forEach(
(method) => {
const lower = method.toLowerCase() as Lowercase<Method>;
(router as any)[lower] = <
P extends string | TypedURLPattern<any> | URLPattern,
>(
path: P,
handler: Handler<HandlerParams<P>>,
) => router.on(method as Method, path, handler);
},
);
return router as Router;
}
export * from "./middleware.ts";

View file

@ -4,142 +4,67 @@
*/ */
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 readonly rejectors: ((error: Error) => void)[] = [];
private _error: Error | null = null; private _error: Error | null = null;
private _closed = false; private _closed = false;
get closed(): boolean { get closed(): boolean {
return this._closed; return this._closed;
} }
write(chunk: T) { write(chunk: T) {
if (this._closed) throw new Error("Cannot write to closed stream"); if (this._closed) throw new Error("Cannot write to closed stream");
const resolver = this.resolvers.shift(); const resolver = this.resolvers.shift();
if (resolver) { if (resolver) {
this.rejectors.shift(); this.rejectors.shift();
resolver({ value: chunk, done: false }); resolver({ value: chunk, done: false });
} else { } else {
this.chunks.push(chunk); this.chunks.push(chunk);
} }
} }
close(): void { close(): void {
this._closed = true; this._closed = true;
while (this.resolvers.length) { while (this.resolvers.length) {
this.rejectors.shift(); 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 { error(err: Error): void {
if (this._closed) return; if (this._closed) return;
this._error = err; this._error = err;
this._closed = true; this._closed = true;
while (this.rejectors.length) { while (this.rejectors.length) {
this.rejectors.shift()!(err); this.rejectors.shift()!(err);
this.resolvers.shift(); this.resolvers.shift();
} }
} }
async next(): Promise<IteratorResult<T>> { async next(): Promise<IteratorResult<T>> {
if (this._error) { if (this._error) {
throw 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, reject) => { return new Promise((resolve, reject) => {
this.resolvers.push(resolve); this.resolvers.push(resolve);
this.rejectors.push(reject); this.rejectors.push(reject);
}); });
} }
[Symbol.asyncIterator](): AsyncIterableIterator<T> { [Symbol.asyncIterator](): AsyncIterableIterator<T> {
return this; return this;
} }
} }
export const mapStream = <T, U>(
fn: (chunk: T, index: number) => U | Promise<U>,
) =>
async function* (source: AsyncIterable<T>): AsyncIterable<U> {
let index = 0;
for await (const chunk of source) yield await fn(chunk, index++);
};
export const filterStream = <T>(
pred: (chunk: T, index: number) => boolean | Promise<boolean>,
) =>
async function* (source: AsyncIterable<T>): AsyncIterable<T> {
let index = 0;
for await (const chunk of source) {
if (await pred(chunk, index++)) yield chunk;
}
};
export const takeStream = <T>(count: number) =>
async function* (source: AsyncIterable<T>): AsyncIterable<T> {
let taken = 0;
for await (const chunk of source) {
if (taken++ >= count) return;
yield chunk;
}
};
export const skipStream = <T>(count: number) =>
async function* (source: AsyncIterable<T>): AsyncIterable<T> {
let index = 0;
for await (const chunk of source) {
if (index++ >= count) yield chunk;
}
};
export const batchStream = <T>(size: number) =>
async function* (source: AsyncIterable<T>): AsyncIterable<T[]> {
let batch: T[] = [];
for await (const chunk of source) {
batch.push(chunk);
if (batch.length >= size) {
yield batch;
batch = [];
}
}
if (batch.length) yield batch;
};
export const tapStream = <T>(
fn: (chunk: T, index: number) => void | Promise<void>,
) =>
async function* (source: AsyncIterable<T>): AsyncIterable<T> {
let index = 0;
for await (const chunk of source) {
yield chunk;
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)));
}
};
export const pipe =
<T>(...fns: Array<(src: AsyncIterable<T>) => AsyncIterable<any>>) =>
(source: AsyncIterable<T>) =>
fns.reduce((acc, fn) => fn(acc), source);

8
src/utils.ts Normal file
View file

@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 favewa
* SPDX-License-Identifier: BSD-3-Clause
*/
export type Promisable<T> = T | Promise<T>;
export type Streamable<T> = T | AsyncIterable<T>;