diff --git a/README.md b/README.md index aa31ac4..930cd04 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ # 🕸️ 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 diff --git a/deno.json b/deno.json index b8aa8c9..0c3d1be 100644 --- a/deno.json +++ b/deno.json @@ -1,21 +1,24 @@ { - "tasks": {}, - "imports": { - "cobweb/jsx-runtime": "./src/jsx.ts" - }, - "fmt": { - "useTabs": true, - "semiColons": true - }, - "lint": { - "rules": { - "tags": ["recommended"], - "exclude": ["no-explicit-any", "require-await"] - } - }, - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "cobweb", - "lib": ["deno.ns", "esnext", "dom", "dom.iterable"] - } + "tasks": {}, + "imports": { + "cobweb/": "./src/", + "cobweb/routing": "./src/router.ts", + "cobweb/helpers": "./src/helpers.ts", + "cobweb/jsx-runtime": "./src/jsx.ts" + }, + "fmt": { + "useTabs": true, + "semiColons": true + }, + "lint": { + "rules": { + "tags": ["recommended"], + "exclude": ["no-explicit-any", "require-await"] + } + }, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "cobweb", + "lib": ["deno.ns", "esnext", "dom", "dom.iterable"] + } } diff --git a/example.tsx b/example.tsx new file mode 100644 index 0000000..bb10cad --- /dev/null +++ b/example.tsx @@ -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 { + for (const todo of todos) { + await new Promise((r) => setTimeout(r, 300)); + yield todo; + } +} + +const Layout = (props: { title: string; children: any }) => ( + + + + + {props.title} + + + {props.children} + +); + +const TodoList = async function* (): AsyncGenerator { + yield
Loading todos...
; + + for await (const todo of fetchTodos()) { + yield ( +
+ {todo.text} +
+ ); + } +}; + +const app = createRouter(); + +app.get("/", async (ctx) => { + const { html } = ctx; + + await render( + +

My Todos

+ + + +
, + html.chunks, + ); + + return html.response; +}); + +app.get("/meow/:test?", async (ctx) => { + console.log(ctx.params.test); +}); + +Deno.serve({ port: 8000 }, app.fetch); diff --git a/scripts/copyright.ts b/scripts/copyright.ts index d65ce00..e05645e 100755 --- a/scripts/copyright.ts +++ b/scripts/copyright.ts @@ -14,16 +14,18 @@ const copyrightHeader = `/** const dir = "./"; -for await (const entry of walk(dir, { - exts: [".ts", ".tsx"], - includeDirs: false, - skip: [/node_modules/, /copyright\.ts$/], -})) { - const filePath = entry.path; - const content = await Deno.readTextFile(filePath); +for await ( + const entry of walk(dir, { + exts: [".ts", ".tsx"], + includeDirs: false, + skip: [/node_modules/, /copyright\.ts$/], + }) +) { + const filePath = entry.path; + const content = await Deno.readTextFile(filePath); - if (!content.startsWith(copyrightHeader)) { - await Deno.writeTextFile(filePath, copyrightHeader + "\n" + content); - console.log(`Added header to ${filePath}`); - } + if (!content.startsWith(copyrightHeader)) { + await Deno.writeTextFile(filePath, copyrightHeader + "\n" + content); + console.log(`Added header to ${filePath}`); + } } diff --git a/src/global.d.ts b/src/global.d.ts index 284bf5b..0021bfa 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -8,32 +8,35 @@ import type { JsxElement } from "cobweb/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; - } + 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; + } >; declare global { - namespace JSX { - type Element = JsxElement; + namespace JSX { + type Element = JsxElement; - export interface ElementChildrenAttribute { - // deno-lint-ignore ban-types - children: {}; - } + 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] - >; - }; - } + export type IntrinsicElements = + & { + [K in keyof HTMLElementTagNameMap]: HTMLAttributeMap< + HTMLElementTagNameMap[K] + >; + } + & { + [K in keyof SVGElementTagNameMap]: HTMLAttributeMap< + SVGElementTagNameMap[K] + >; + }; + } } diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..cdc8d56 --- /dev/null +++ b/src/helpers.ts @@ -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, + 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 }); +} diff --git a/src/html.ts b/src/html.ts index b647ca7..930d487 100644 --- a/src/html.ts +++ b/src/html.ts @@ -7,134 +7,134 @@ import { type Chunk } from "./http.ts"; import { ChunkedStream } from "./stream.ts"; export type Attrs = Record< - string, - string | number | boolean | null | undefined + 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", + "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; + 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; - } - } + 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; + return lastIndex ? result + input.slice(lastIndex) : input; } function serialize(attrs: Attrs | undefined): string { - if (!attrs) return ""; - let output = ""; + 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))}"`; - } + 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; + 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; + (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; + [key: string]: TagFn; }; 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 => - arg && - typeof arg === "object" && - !isTemplateLiteral(arg) && - !Array.isArray(arg) && - !(arg instanceof Promise); + arg && + typeof arg === "object" && + !isTemplateLiteral(arg) && + !Array.isArray(arg) && + !(arg instanceof Promise); async function render(child: unknown): Promise { - if (child == null) return ""; + 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 (typeof child === "string") return escape(child); + if (typeof child === "number") return String(child); + if (typeof child === "boolean") return String(Number(child)); - if (child instanceof Promise) return render(await child); + if (child instanceof Promise) return render(await child); - if (Array.isArray(child)) { - return (await Promise.all(child.map(render))).join(""); - } + if (Array.isArray(child)) { + return (await Promise.all(child.map(render))).join(""); + } - if (typeof child === "function") return render(await child()); - return escape(String(child)); + 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 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; + 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; + 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); + const isVoid = VOID_TAGS.has(tag.toLowerCase()); + const attributes = serialize(attrs as Attrs); - write(`<${tag}${attributes}${isVoid ? "/" : ""}>`); - if (isVoid) return; + write(`<${tag}${attributes}${isVoid ? "/" : ""}>`); + if (isVoid) return; - for (const child of args) { - write(await render(child)); - } + for (const child of args) { + write(await render(child)); + } - write(``); - }; + write(``); + }; - return (cache.set(tag, fn), fn); - }, - }; + return (cache.set(tag, fn), fn); + }, + }; - const proxy = new Proxy({}, handler) as HtmlProxy; - return proxy; + const proxy = new Proxy({}, handler) as HtmlProxy; + return proxy; } diff --git a/src/http.ts b/src/http.ts index 30e3483..0d65c4c 100644 --- a/src/http.ts +++ b/src/http.ts @@ -6,127 +6,127 @@ import { ChunkedStream } from "./stream.ts"; export interface StreamOptions { - headContent?: string; - bodyAttributes?: string; - lang?: string; + headContent?: string; + bodyAttributes?: string; + lang?: string; } export type Chunk = - | string - | AsyncIterable - | Promise - | Iterable - | null - | undefined; + | string + | AsyncIterable + | Promise + | Iterable + | null + | undefined; async function* normalize( - value: Chunk | undefined | null, + value: Chunk | undefined | null, ): AsyncIterable { - if (value == null) return; + 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); - } + 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[] + 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)); + (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 (let i = 0; i < strings.length; i++) { + strings[i] && emit(strings[i]); - for await (const chunk of normalize(values[i])) { - emit(chunk); - } - } - }; + for await (const chunk of normalize(values[i])) { + emit(chunk); + } + } + }; export function chunkedHtml() { - const chunks = new ChunkedStream(); + const chunks = new ChunkedStream(); - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - try { - for await (const chunk of chunks) { - controller.enqueue(encoder.encode(chunk)); - } - controller.close(); - } catch (error) { - controller.error(error); - } - }, - cancel: chunks.close, - }); + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + try { + for await (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } catch (error) { + controller.error(error); + } + }, + cancel: chunks.close, + }); - return { chunks, stream }; + return { chunks, stream }; } const DOCUMENT_TYPE = ""; const HTML_BEGIN = (lang: string) => - ``; + ``; const HEAD_END = "; - chunks: ChunkedStream; - close(): void; - error(err: Error): void; - readonly response: Response; + write: ChunkedWriter; + blob: ReadableStream; + chunks: ChunkedStream; + close(): void; + error(err: Error): void; + readonly response: Response; } export async function createHtmlStream( - options: StreamOptions = {}, + options: StreamOptions = {}, ): Promise { - const { chunks, stream } = chunkedHtml(); - const writer = makeChunkWriter(chunks); + 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); + 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); - return { - write: writer, - blob: stream, - chunks, - close() { - if (!chunks.closed) { - chunks.write(HTML_END); - chunks.close(); - } - }, - 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", - }, - }), - }; + return { + write: writer, + blob: stream, + chunks, + close() { + if (!chunks.closed) { + chunks.write(HTML_END); + chunks.close(); + } + }, + 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/index.ts b/src/index.ts deleted file mode 100644 index d0462f5..0000000 --- a/src/index.ts +++ /dev/null @@ -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"; diff --git a/src/jsx.ts b/src/jsx.ts index 911a72c..35af369 100644 --- a/src/jsx.ts +++ b/src/jsx.ts @@ -3,121 +3,192 @@ * 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 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 = (props: Props) => JsxElement | AsyncGenerator; +type Component

= (props: P) => Promisable>; -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))); +interface DeferProps { + fallback?: JsxChild; + children: JsxChild; } -export function jsx( - tag: string | Component | typeof Fragment, - props: Props | null = {}, +type Props = { + children?: JsxChild | JsxChild[]; + key?: string | number; +}; + +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))); +} + +export function jsx

( + tag: string | Component

| typeof Fragment | typeof Defer, + props: Props | null = {} as P, ): JsxElement { - props ||= {}; + props ??= {} as P; - return async (chunks: ChunkedStream) => { - const context = html(chunks); - const { children, ...attrs } = 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 (tag === Fragment) { + for (const child of Array.isArray(children) ? children : [children]) { + await render(child, chunks, context); + } + return; + } - if (typeof tag === "function") { - return await render(tag(props), chunks, context); - } + if (tag === Defer) { + const { fallback = "", children } = props as DeferProps; + const id = `s${Math.random().toString(36).slice(2)}`; - const childr = children == null ? [] : [].concat(children); - const attributes = Object.keys(attrs).length ? attrs : {}; + write(chunks, `

`); + await render(fallback, chunks, context); + write(chunks, `
`); - 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); - } - }); - } - }; + 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, ``); + } + }; } export const jsxs = jsx; async function renderJsx( - element: JsxElement | JsxElement[], - chunks: ChunkedStream, + 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); - } + 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)); + (html: string): JsxElement => async (chunks: ChunkedStream) => + void (!chunks.closed && chunks.write(html)); export const open = (tag: K) => - raw(`<${tag}>`); + raw(`<${tag}>`); export const close = (tag: K) => - raw(``); + raw(``); export { renderJsx as render }; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..36ad5fd --- /dev/null +++ b/src/middleware.ts @@ -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> { + readonly request: Request; + readonly url: URL; + readonly method: string; + readonly params: Params; + readonly pattern: URLPatternResult; + readonly html: HtmlStream; + readonly signal: AbortSignal; + state: Map; +} + +export async function createContext

>( + request: Request, + pattern: URLPatternResult, + html: HtmlStream, +): Promise> { + 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

> { + (ctx: Context

): Promisable; +} + +export interface Middleware { + (ctx: Context, next: () => Promise): Promisable; +} + +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 { + 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); + }; +} diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..d6457af --- /dev/null +++ b/src/router.ts @@ -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}:${infer Param}/${infer Rest}` + ? Param | ExtractParameterNames<`/${Rest}`> + : S extends `${string}:${infer Param}` ? Param + : never; + +type Skippable = S extends `${string}?` ? T | undefined + : T; + +type StripOptional = S extends `${infer P}?` ? P : S; + +export type ParametersOf = { + [K in ExtractParameterNames as StripOptional]: Skippable< + K, + string + >; +}; + +export interface TypedURLPattern extends URLPattern { + readonly raw: S; +} + +export function url( + init: URLPatternInit & { pathname: S }, +): TypedURLPattern { + const pattern = new URLPattern(init) as TypedURLPattern; + return (((pattern as any).raw = init.pathname), pattern); +} + +export interface Route { + readonly pattern: URLPattern; + handler: Handler; + method: Method; +} + +type HandlerParams

= P extends TypedURLPattern ? ParametersOf + : P extends string ? ParametersOf

+ : P extends URLPattern ? Record + : never; + +interface BaseRouter { + routes: Route[]; + middlewares: Middleware[]; + namespace?: string; + + use: (...middlewares: Middleware[]) => this; + + on

| URLPattern>( + method: Method, + path: P, + handler: Handler>, + ): P; + + fetch: (request: Request) => Promise; +} + +type Router = + & BaseRouter + & { + [M in Method as Lowercase]: < + P extends string | TypedURLPattern | URLPattern, + >( + path: P, + handler: Handler>, + ) => 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

| URLPattern>( + method: Method, + path: P, + handler: Handler>, + ): 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 { + 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; + (router as any)[lower] = < + P extends string | TypedURLPattern | URLPattern, + >( + path: P, + handler: Handler>, + ) => router.on(method as Method, path, handler); + }, + ); + + return router as Router; +} + +export * from "./middleware.ts"; diff --git a/src/stream.ts b/src/stream.ts index a63427e..5f3a9ca 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -4,142 +4,67 @@ */ export class ChunkedStream implements AsyncIterable { - private readonly chunks: T[] = []; + private readonly chunks: T[] = []; - private readonly resolvers: ((result: IteratorResult) => void)[] = []; - private readonly rejectors: ((error: Error) => void)[] = []; + private readonly resolvers: ((result: IteratorResult) => void)[] = []; + private readonly rejectors: ((error: Error) => void)[] = []; - private _error: Error | null = null; - private _closed = false; + private _error: Error | null = null; + private _closed = false; - get closed(): boolean { - return this._closed; - } + get closed(): boolean { + return this._closed; + } - write(chunk: T) { - if (this._closed) throw new Error("Cannot write to closed stream"); + write(chunk: T) { + if (this._closed) throw new Error("Cannot write to closed stream"); - const resolver = this.resolvers.shift(); - if (resolver) { - this.rejectors.shift(); - resolver({ value: chunk, done: false }); - } else { - this.chunks.push(chunk); - } - } + const resolver = this.resolvers.shift(); + if (resolver) { + this.rejectors.shift(); + resolver({ value: chunk, done: false }); + } else { + this.chunks.push(chunk); + } + } - close(): void { - this._closed = true; - while (this.resolvers.length) { - this.rejectors.shift(); - this.resolvers.shift()!({ value: undefined! as any, done: true }); - } - } + 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; + error(err: Error): void { + if (this._closed) return; - this._error = err; - this._closed = true; + this._error = err; + this._closed = true; - while (this.rejectors.length) { - this.rejectors.shift()!(err); - this.resolvers.shift(); - } - } + while (this.rejectors.length) { + this.rejectors.shift()!(err); + this.resolvers.shift(); + } + } - async next(): Promise> { - if (this._error) { - throw this._error; - } + 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 }; + 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, reject) => { - this.resolvers.push(resolve); - this.rejectors.push(reject); - }); - } + return new Promise((resolve, reject) => { + this.resolvers.push(resolve); + this.rejectors.push(reject); + }); + } - [Symbol.asyncIterator](): AsyncIterableIterator { - return this; - } + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + } } - -export const mapStream = ( - fn: (chunk: T, index: number) => U | Promise, -) => - async function* (source: AsyncIterable): AsyncIterable { - let index = 0; - for await (const chunk of source) yield await fn(chunk, index++); - }; - -export const filterStream = ( - pred: (chunk: T, index: number) => boolean | Promise, -) => - async function* (source: AsyncIterable): AsyncIterable { - let index = 0; - for await (const chunk of source) { - if (await pred(chunk, index++)) yield chunk; - } - }; - -export const takeStream = (count: number) => - async function* (source: AsyncIterable): AsyncIterable { - let taken = 0; - for await (const chunk of source) { - if (taken++ >= count) return; - yield chunk; - } - }; - -export const skipStream = (count: number) => - async function* (source: AsyncIterable): AsyncIterable { - let index = 0; - for await (const chunk of source) { - if (index++ >= count) yield chunk; - } - }; - -export const batchStream = (size: number) => - async function* (source: AsyncIterable): AsyncIterable { - 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 = ( - fn: (chunk: T, index: number) => void | Promise, -) => - async function* (source: AsyncIterable): AsyncIterable { - let index = 0; - for await (const chunk of source) { - yield chunk; - 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))); - } - }; - -export const pipe = - (...fns: Array<(src: AsyncIterable) => AsyncIterable>) => - (source: AsyncIterable) => - fns.reduce((acc, fn) => fn(acc), source); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..99def07 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2025 favewa + * SPDX-License-Identifier: BSD-3-Clause + */ + +export type Promisable = T | Promise; + +export type Streamable = T | AsyncIterable;