implementing routing and streamline tsx runtime
This commit is contained in:
parent
3d733cfe0b
commit
4af7b21171
14 changed files with 820 additions and 451 deletions
17
README.md
17
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
41
deno.json
41
deno.json
|
|
@ -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
85
example.tsx
Normal 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);
|
||||||
|
|
@ -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
49
src/global.d.ts
vendored
|
|
@ -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
46
src/helpers.ts
Normal 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 });
|
||||||
|
}
|
||||||
168
src/html.ts
168
src/html.ts
|
|
@ -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> = {
|
||||||
"&": "&",
|
"&": "&",
|
||||||
"<": "<",
|
"<": "<",
|
||||||
">": ">",
|
">": ">",
|
||||||
'"': """,
|
'"': """,
|
||||||
"'": "'",
|
"'": "'",
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
178
src/http.ts
178
src/http.ts
|
|
@ -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",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
253
src/jsx.ts
253
src/jsx.ts
|
|
@ -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
68
src/middleware.ts
Normal 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
151
src/router.ts
Normal 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";
|
||||||
175
src/stream.ts
175
src/stream.ts
|
|
@ -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
8
src/utils.ts
Normal 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>;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue