initial commit
This commit is contained in:
commit
3d733cfe0b
11 changed files with 685 additions and 0 deletions
10
LICENSE
Normal file
10
LICENSE
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
Copyright (c) 2025 favewa
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# 🕸️ cobweb
|
||||
|
||||
a lightweight, tiny web framework for deno
|
||||
21
deno.json
Normal file
21
deno.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
35
deno.lock
generated
Normal file
35
deno.lock
generated
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"version": "5",
|
||||
"redirects": {
|
||||
"https://deno.land/std/fs/walk.ts": "https://deno.land/std@0.224.0/fs/walk.ts"
|
||||
},
|
||||
"remote": {
|
||||
"https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834",
|
||||
"https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917",
|
||||
"https://deno.land/std@0.224.0/fs/_create_walk_entry.ts": "5d9d2aaec05bcf09a06748b1684224d33eba7a4de24cf4cf5599991ca6b5b412",
|
||||
"https://deno.land/std@0.224.0/fs/_to_path_string.ts": "29bfc9c6c112254961d75cbf6ba814d6de5349767818eb93090cecfa9665591e",
|
||||
"https://deno.land/std@0.224.0/fs/walk.ts": "cddf87d2705c0163bff5d7767291f05b0f46ba10b8b28f227c3849cace08d303",
|
||||
"https://deno.land/std@0.224.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8",
|
||||
"https://deno.land/std@0.224.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2",
|
||||
"https://deno.land/std@0.224.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c",
|
||||
"https://deno.land/std@0.224.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf",
|
||||
"https://deno.land/std@0.224.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8",
|
||||
"https://deno.land/std@0.224.0/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3",
|
||||
"https://deno.land/std@0.224.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a",
|
||||
"https://deno.land/std@0.224.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15",
|
||||
"https://deno.land/std@0.224.0/path/basename.ts": "7ee495c2d1ee516ffff48fb9a93267ba928b5a3486b550be73071bc14f8cc63e",
|
||||
"https://deno.land/std@0.224.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069",
|
||||
"https://deno.land/std@0.224.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a",
|
||||
"https://deno.land/std@0.224.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352",
|
||||
"https://deno.land/std@0.224.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d",
|
||||
"https://deno.land/std@0.224.0/path/posix/basename.ts": "d2fa5fbbb1c5a3ab8b9326458a8d4ceac77580961b3739cd5bfd1d3541a3e5f0",
|
||||
"https://deno.land/std@0.224.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40",
|
||||
"https://deno.land/std@0.224.0/path/posix/join.ts": "7fc2cb3716aa1b863e990baf30b101d768db479e70b7313b4866a088db016f63",
|
||||
"https://deno.land/std@0.224.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91",
|
||||
"https://deno.land/std@0.224.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808",
|
||||
"https://deno.land/std@0.224.0/path/windows/basename.ts": "6bbc57bac9df2cec43288c8c5334919418d784243a00bc10de67d392ab36d660",
|
||||
"https://deno.land/std@0.224.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01",
|
||||
"https://deno.land/std@0.224.0/path/windows/join.ts": "8d03530ab89195185103b7da9dfc6327af13eabdcd44c7c63e42e27808f50ecf",
|
||||
"https://deno.land/std@0.224.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780"
|
||||
}
|
||||
}
|
||||
29
scripts/copyright.ts
Executable file
29
scripts/copyright.ts
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env -S deno run --allow-read --allow-write
|
||||
/**
|
||||
* Copyright (c) 2025 favewa
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
import { walk } from "https://deno.land/std/fs/walk.ts";
|
||||
|
||||
const copyrightHeader = `/**
|
||||
* Copyright (c) 2025 favewa
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
`;
|
||||
|
||||
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);
|
||||
|
||||
if (!content.startsWith(copyrightHeader)) {
|
||||
await Deno.writeTextFile(filePath, copyrightHeader + "\n" + content);
|
||||
console.log(`Added header to ${filePath}`);
|
||||
}
|
||||
}
|
||||
39
src/global.d.ts
vendored
Normal file
39
src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Copyright (c) 2025 favewa
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
/// <reference types="cobweb/jsx-runtime" />
|
||||
|
||||
import type { JsxElement } from "cobweb/jsx-runtime";
|
||||
|
||||
type HTMLAttributeMap<T = HTMLElement> = Partial<
|
||||
Omit<T, keyof Element | "children" | "style"> & {
|
||||
style?: string;
|
||||
class?: string;
|
||||
children?: any;
|
||||
[key: `data-${string}`]: string | number | boolean | null | undefined;
|
||||
[key: `aria-${string}`]: string | number | boolean | null | undefined;
|
||||
}
|
||||
>;
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = JsxElement;
|
||||
|
||||
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]
|
||||
>;
|
||||
};
|
||||
}
|
||||
}
|
||||
140
src/html.ts
Normal file
140
src/html.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Copyright (c) 2025 favewa
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
import { type Chunk } from "./http.ts";
|
||||
import { ChunkedStream } from "./stream.ts";
|
||||
|
||||
export type Attrs = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
|
||||
export const VOID_TAGS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
export function escape(input: string): string {
|
||||
let result = "";
|
||||
let lastIndex = 0;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const replacement = ESCAPE_MAP[input[i]];
|
||||
if (replacement) {
|
||||
result += input.slice(lastIndex, i) + replacement;
|
||||
lastIndex = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return lastIndex ? result + input.slice(lastIndex) : input;
|
||||
}
|
||||
|
||||
function serialize(attrs: Attrs | undefined): string {
|
||||
if (!attrs) return "";
|
||||
let output = "";
|
||||
|
||||
for (const key in attrs) {
|
||||
const val = attrs[key];
|
||||
if (val == null || val === false) continue;
|
||||
output += " ";
|
||||
output += val === true ? key : `${key}="${escape(String(val))}"`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
type TagRes = void | Promise<void>;
|
||||
|
||||
type TagFn = {
|
||||
(attrs: Attrs, ...children: Chunk[]): TagRes;
|
||||
(attrs: Attrs, fn: () => any): TagRes;
|
||||
(...children: Chunk[]): TagRes;
|
||||
(template: TemplateStringsArray, ...values: Chunk[]): TagRes;
|
||||
(fn: () => any): TagRes;
|
||||
};
|
||||
|
||||
export type HtmlProxy = { [K in keyof HTMLElementTagNameMap]: TagFn } & {
|
||||
[key: string]: TagFn;
|
||||
};
|
||||
|
||||
const isTemplateLiteral = (arg: any): arg is TemplateStringsArray =>
|
||||
Array.isArray(arg) && "raw" in arg;
|
||||
|
||||
const isAttributes = (arg: any): arg is Record<string, any> =>
|
||||
arg &&
|
||||
typeof arg === "object" &&
|
||||
!isTemplateLiteral(arg) &&
|
||||
!Array.isArray(arg) &&
|
||||
!(arg instanceof Promise);
|
||||
|
||||
async function render(child: unknown): Promise<string> {
|
||||
if (child == null) return "";
|
||||
|
||||
if (typeof child === "string") return escape(child);
|
||||
if (typeof child === "number") return String(child);
|
||||
if (typeof child === "boolean") return String(Number(child));
|
||||
|
||||
if (child instanceof Promise) return render(await child);
|
||||
|
||||
if (Array.isArray(child)) {
|
||||
return (await Promise.all(child.map(render))).join("");
|
||||
}
|
||||
|
||||
if (typeof child === "function") return render(await child());
|
||||
return escape(String(child));
|
||||
}
|
||||
|
||||
export function html(chunks: ChunkedStream<string>): HtmlProxy {
|
||||
const cache = new Map<string, TagFn>();
|
||||
const write = (buf: string) => !chunks.closed && chunks.write(buf);
|
||||
|
||||
const handler: ProxyHandler<Record<string, TagFn>> = {
|
||||
get(_, tag: string) {
|
||||
let fn = cache.get(tag);
|
||||
if (fn) return fn;
|
||||
|
||||
fn = async (...args: unknown[]) => {
|
||||
const attrs = isAttributes(args[0]) ? args.shift() : undefined;
|
||||
|
||||
const isVoid = VOID_TAGS.has(tag.toLowerCase());
|
||||
const attributes = serialize(attrs as Attrs);
|
||||
|
||||
write(`<${tag}${attributes}${isVoid ? "/" : ""}>`);
|
||||
if (isVoid) return;
|
||||
|
||||
for (const child of args) {
|
||||
write(await render(child));
|
||||
}
|
||||
|
||||
write(`</${tag}>`);
|
||||
};
|
||||
|
||||
return (cache.set(tag, fn), fn);
|
||||
},
|
||||
};
|
||||
|
||||
const proxy = new Proxy({}, handler) as HtmlProxy;
|
||||
return proxy;
|
||||
}
|
||||
132
src/http.ts
Normal file
132
src/http.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Copyright (c) 2025 favewa
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
import { ChunkedStream } from "./stream.ts";
|
||||
|
||||
export interface StreamOptions {
|
||||
headContent?: string;
|
||||
bodyAttributes?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export type Chunk =
|
||||
| string
|
||||
| AsyncIterable<string>
|
||||
| Promise<string>
|
||||
| Iterable<string>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
async function* normalize(
|
||||
value: Chunk | undefined | null,
|
||||
): AsyncIterable<string> {
|
||||
if (value == null) return;
|
||||
|
||||
if (typeof value === "string") {
|
||||
yield value;
|
||||
} else if (value instanceof Promise) {
|
||||
const resolved = await value;
|
||||
if (resolved != null) yield String(resolved);
|
||||
} else if (Symbol.asyncIterator in value || Symbol.iterator in value) {
|
||||
for await (const chunk of value as AsyncIterable<string>) {
|
||||
if (chunk != null) yield String(chunk);
|
||||
}
|
||||
} else {
|
||||
yield String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export type ChunkedWriter = (
|
||||
strings: TemplateStringsArray,
|
||||
...values: Chunk[]
|
||||
) => Promise<void>;
|
||||
|
||||
export const makeChunkWriter =
|
||||
(stream: ChunkedStream<string>): ChunkedWriter =>
|
||||
async (strings, ...values) => {
|
||||
const emit = (chunk: string) =>
|
||||
!stream.closed &&
|
||||
(chunk === "EOF" ? stream.close() : stream.write(chunk));
|
||||
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
strings[i] && emit(strings[i]);
|
||||
|
||||
for await (const chunk of normalize(values[i])) {
|
||||
emit(chunk);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function chunkedHtml() {
|
||||
const chunks = new ChunkedStream<string>();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
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 };
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPE = "<!DOCTYPE html>";
|
||||
const HTML_BEGIN = (lang: string) =>
|
||||
`<html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">`;
|
||||
const HEAD_END = "</head><body";
|
||||
const BODY_END = ">";
|
||||
const HTML_END = "</body></html>";
|
||||
|
||||
export interface HtmlStream {
|
||||
write: ChunkedWriter;
|
||||
blob: ReadableStream<Uint8Array>;
|
||||
chunks: ChunkedStream<string>;
|
||||
close(): void;
|
||||
error(err: Error): void;
|
||||
readonly response: Response;
|
||||
}
|
||||
|
||||
export async function createHtmlStream(
|
||||
options: StreamOptions = {},
|
||||
): Promise<HtmlStream> {
|
||||
const { chunks, stream } = chunkedHtml();
|
||||
const writer = makeChunkWriter(chunks);
|
||||
|
||||
chunks.write(DOCUMENT_TYPE);
|
||||
chunks.write(HTML_BEGIN(options.lang || "en"));
|
||||
options.headContent && chunks.write(options.headContent);
|
||||
chunks.write(HEAD_END);
|
||||
options.bodyAttributes && chunks.write(" " + options.bodyAttributes);
|
||||
chunks.write(BODY_END);
|
||||
|
||||
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",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
8
src/index.ts
Normal file
8
src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Copyright (c) 2025 favewa
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
export * from "./html.ts";
|
||||
export * from "./http.ts";
|
||||
export * from "./jsx.ts";
|
||||
123
src/jsx.ts
Normal file
123
src/jsx.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Copyright (c) 2025 favewa
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
import { Attrs, escape, html, VOID_TAGS } from "./html.ts";
|
||||
import { ChunkedStream } from "./stream.ts";
|
||||
|
||||
export const Fragment = Symbol("Fragment");
|
||||
|
||||
type Props = Attrs & { children?: any };
|
||||
type Component = (props: Props) => JsxElement | AsyncGenerator<JsxElement>;
|
||||
|
||||
export type JsxElement =
|
||||
| ((chunks: ChunkedStream<string>) => Promise<void>)
|
||||
| AsyncGenerator<JsxElement, void, unknown>;
|
||||
|
||||
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(
|
||||
tag: string | Component | typeof Fragment,
|
||||
props: Props | null = {},
|
||||
): JsxElement {
|
||||
props ||= {};
|
||||
|
||||
return async (chunks: ChunkedStream<string>) => {
|
||||
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 (typeof tag === "function") {
|
||||
return await render(tag(props), chunks, context);
|
||||
}
|
||||
|
||||
const childr = children == null ? [] : [].concat(children);
|
||||
const attributes = Object.keys(attrs).length ? attrs : {};
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const jsxs = jsx;
|
||||
|
||||
async function renderJsx(
|
||||
element: JsxElement | JsxElement[],
|
||||
chunks: ChunkedStream<string>,
|
||||
): Promise<void> {
|
||||
if (Array.isArray(element)) {
|
||||
for (const el of element) {
|
||||
await renderJsx(el, chunks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
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<string>) =>
|
||||
void (!chunks.closed && chunks.write(html));
|
||||
|
||||
export const open = <K extends keyof HTMLElementTagNameMap>(tag: K) =>
|
||||
raw(`<${tag}>`);
|
||||
|
||||
export const close = <K extends keyof HTMLElementTagNameMap>(tag: K) =>
|
||||
raw(`</${tag}>`);
|
||||
|
||||
export { renderJsx as render };
|
||||
145
src/stream.ts
Normal file
145
src/stream.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Copyright (c) 2025 favewa
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
export class ChunkedStream<T> implements AsyncIterable<T> {
|
||||
private readonly chunks: T[] = [];
|
||||
|
||||
private readonly resolvers: ((result: IteratorResult<T>) => void)[] = [];
|
||||
private readonly rejectors: ((error: Error) => void)[] = [];
|
||||
|
||||
private _error: Error | null = null;
|
||||
private _closed = false;
|
||||
|
||||
get closed(): boolean {
|
||||
return this._closed;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
this._error = err;
|
||||
this._closed = true;
|
||||
|
||||
while (this.rejectors.length) {
|
||||
this.rejectors.shift()!(err);
|
||||
this.resolvers.shift();
|
||||
}
|
||||
}
|
||||
|
||||
async next(): Promise<IteratorResult<T>> {
|
||||
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 };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.resolvers.push(resolve);
|
||||
this.rejectors.push(reject);
|
||||
});
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterableIterator<T> {
|
||||
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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue