diff --git a/.changeset/angry-knives-hope.md b/.changeset/angry-knives-hope.md new file mode 100644 index 000000000..9c28ff976 --- /dev/null +++ b/.changeset/angry-knives-hope.md @@ -0,0 +1,5 @@ +--- +'@kitajs/html': minor +--- + +Added support for Generators diff --git a/.changeset/light-grapes-peel.md b/.changeset/light-grapes-peel.md new file mode 100644 index 000000000..b0cf122ad --- /dev/null +++ b/.changeset/light-grapes-peel.md @@ -0,0 +1,5 @@ +--- +'@kitajs/html': patch +--- + +Deprecated key attribute diff --git a/examples/format.ts b/examples/format.ts new file mode 100644 index 000000000..d926826e6 --- /dev/null +++ b/examples/format.ts @@ -0,0 +1,280 @@ +import { + type BackgroundCheckListener, + type DetailedHealthCheck, + type HealthCheckResult, + type HealthChecker, + type HealthCheckerMap, + HealthStatus, + type MedicusErrorLogger, + type MedicusOption +} from './types'; + +/** + * **Medicus** + * + * A flexible and agnostic health check library for Node.js. + * + * @example + * + * ```ts + * import { Medicus, HealthStatus } from 'medicus'; + * + * const medicus = new Medicus(); + * + * // Add health checkers + * medicus.addChecker({ + * database() { + * // Custom health logic + * return HealthStatus.HEALTHY; + * }, + * cache() { + * // Simulate an unhealthy status + * return HealthStatus.UNHEALTHY; + * } + * }); + * + * // Perform a health check + * const result = await medicus.performCheck(true); + * // { + * // status: 'UNHEALTHY', + * // services: { + * // database: { status: 'HEALTHY' }, + * // cache: { status: 'UNHEALTHY' } + * // } + * // } + * ``` + * + * @see https://medicus.js.org + * @see https://github.com/arthurfiorette/medicus + */ +export class Medicus { + /** The interval id of the background check if it's running */ + protected backgroundCheckTimer: NodeJS.Timeout | null = null; + + /** + * A map of all the checkers that will be executed when the health check is run with the + * key being the name of the checker + */ + protected readonly checkers: Map> = new Map(); + + /** + * The context that will be passed to all the checkers when they get executed> This + * value can can be changed at any time. + */ + public context: Ctx = null as Ctx; + + /** + * The error logger function that will be called whenever an error occurs during the + * execution of a health check> This value can can be changed at any time. + */ + public errorLogger: MedicusErrorLogger | null = null; + + /** + * The last health check result, this is updated every time a health check is run and + * can be accessed with `getLastCheck`> This value can can be changed at any time. + */ + public lastCheck: HealthCheckResult | null = null; + + /** + * The background check defined by the constructor.> This value can can be changed at + * any time. + */ + public onBackgroundCheck: BackgroundCheckListener | null = null; + + constructor(options: MedicusOption = {}) { + if (options.context) { + this.context = options.context; + } + + if (options.errorLogger) { + this.errorLogger = options.errorLogger; + } + + if (options.onBackgroundCheck) { + this.onBackgroundCheck = options.onBackgroundCheck; + } + + if (options.checkers) { + this.addChecker(options.checkers); + } + + if (options.backgroundCheckInterval) { + this.startBackgroundCheck(options.backgroundCheckInterval); + } + } + + /** Adds a new checker to be executed when the health check is run */ + addChecker(checkers: HealthCheckerMap): void { + for (const name in checkers) { + if (this.checkers.has(name)) { + throw new Error(`A checker with the name "${name}" is already registered`); + } + + this.checkers.set(name, checkers[name]!); + } + } + + /** Returns an read-only iterator of all the checkers */ + listCheckers(): MapIterator> { + return this.checkers.values(); + } + + /** + * Removes a checker from the list of checkers to be executed + * + * @returns `true` if all provided checkers were removed, `false` otherwise + */ + removeChecker(...checkerNames: string[]): boolean { + let allRemoved = true; + + for (const name of checkerNames) { + const deleted = this.checkers.delete(name); + + if (!deleted) { + allRemoved = false; + } + } + + return allRemoved; + } + + /** + * Returns a shallow copy of the last health check result with debug information if it's + * set + * + * - `debug` defaults to `false` + */ + getLastCheck(debug = false): HealthCheckResult | null { + if (!this.lastCheck) { + return null; + } + + return { + status: this.lastCheck.status, + services: debug ? this.lastCheck.services : {} + }; + } + + /** + * Performs a health check and returns the result + * + * - `debug` defaults to `false` + */ + async performCheck(debug = false): Promise { + let status = HealthStatus.HEALTHY; + const services: Record = {}; + + for await (const [serviceName, result] of Array.from( + this.checkers, + this.mapChecker, + this + )) { + if (result.status === HealthStatus.UNHEALTHY) { + status = HealthStatus.UNHEALTHY; + } + + services[serviceName] = result; + } + + // updates the last check result + this.lastCheck = { + status, + services + }; + + return this.getLastCheck(debug)!; + } + + /** Simple helper function to yield the result of a health check */ + protected async mapChecker([name, checker]: [string, HealthChecker]) { + return [name, await this.executeChecker(checker)] as const; + } + + /** + * Runs a single health check and returns the result + * + * **This function never throws** + */ + protected async executeChecker( + checker: HealthChecker + ): Promise { + try { + const check = await checker(this.context!); + + switch (typeof check) { + case 'string': + return { status: check }; + case 'object': + return check; + default: + return { status: HealthStatus.HEALTHY }; + } + } catch (error) { + this.errorLogger?.(error, checker.name); + + return { + status: HealthStatus.UNHEALTHY, + debug: { error: String(error) } + }; + } + } + + /** + * Bound function to be passed as reference that performs the background check and calls + * the `onBackgroundCheck` callback if it's set + */ + protected static async performBackgroundCheck( + this: void, + self: Medicus + ): Promise { + const result = await self.performCheck(true); + + // Calls the onBackgroundCheck callback if it's set + if (self.onBackgroundCheck) { + try { + await self.onBackgroundCheck(result); + } catch (error) { + // nothing we can do if there isn't a logger + self.errorLogger?.(error, 'onBackgroundCheck'); + } + } + + // Runs the background check again with the same interval + // unless it was manually removed + self.backgroundCheckTimer?.refresh(); + } + + /** Starts the background check if it's not already running */ + startBackgroundCheck(interval: number) { + if ( + // already running + this.backgroundCheckTimer || + // invalid interval + interval < 1 + ) { + return; + } + + // Un-refs the timer so it doesn't keep the process running + this.backgroundCheckTimer = setTimeout( + Medicus.performBackgroundCheck, + interval, + this + ).unref(); + } + + /** Stops the background check if it's running */ + stopBackgroundCheck(): void { + if (!this.backgroundCheckTimer) { + return; + } + + clearTimeout(this.backgroundCheckTimer)!; + this.backgroundCheckTimer = null; + } + + // to be used as `using medicus = new Medicus()` + [Symbol.dispose]() { + return this.stopBackgroundCheck(); + } +} diff --git a/examples/http-server.tsx b/examples/http-server.tsx index b259d57c8..0eab24647 100644 --- a/examples/http-server.tsx +++ b/examples/http-server.tsx @@ -1,55 +1,193 @@ -import Html, { type PropsWithChildren } from '@kitajs/html'; -import { Suspense, renderToStream } from '@kitajs/html/suspense'; -import http from 'node:http'; -import { setTimeout } from 'node:timers/promises'; - -async function SleepForMs({ ms, children }: PropsWithChildren<{ ms: number }>) { - await setTimeout(ms * 2); - return Html.contentsToString([children || String(ms)]); -} +import type { Join, PartialTuple } from './types'; -function renderLayout(rid: number | string) { - return ( - -
- {Array.from({ length: 5 }, (_, i) => ( - {i} Fallback Outer!
}> -
Outer {i}!
- - - {i} Fallback Inner!}> - -
Inner {i}!
-
-
-
- - ))} - - - ); -} +/** + * A safe, typed, enumerable bidirectional map that ensures unique values and supports + * compound keys. + * + * @template K - A tuple representing the structure of the compound key, composed of + * strings or numbers. + * @template V - The type of the values stored in the map. Defaults to `string`. + * @template S - The type of the separator string used to join keys. Defaults to `' '`. + */ +export class UbiMap< + K extends (string | number)[], + V extends string | number = string, + const S extends string = ' ' +> { + /** Internal map for storing keys mapped to values. */ + private kmap: Record, V> = Object.create(null); + + /** Internal map for storing values mapped to keys. */ + private vmap: Record> = Object.create(null); -http - .createServer((req, response) => { - // This simple webserver only has a index.html file - if (req.url !== '/' && req.url !== '/index.html') { - response.end(); - return; + /** + * Creates a new instance of `UbiMap`. + * + * @param data - An optional initial dataset for the map, where keys are joined strings + * and values are of type `V`. + * @param separator - The string used to separate components of compound keys. Defaults + * to `' '`. + */ + constructor( + data?: Record, V>, + private readonly separator: S = ' ' as S + ) { + if (data) { + for (const [key, value] of Object.entries(data)) { + this.set(key as Join, value); + } } + } - // ⚠️ Charset utf8 is important to avoid old browsers utf7 xss attacks - response.setHeader('Content-Type', 'text/html; charset=utf-8'); + /** + * Adds a new key-value pair to the map. Both keys and values must be unique. + * + * @example + * + * ```ts + * const ubimap = new UbiMap<[string, string]>(); + * + * // Keys as separate arguments + * ubimap.set('key1', 'key2', 'value'); + * + * // Key in a single string + * ubimap.set('key1 key2', 'value'); + * ``` + * + * @param input - A tuple containing the components of the key followed by the value. + * @throws Will throw an error if the key or value already exists in the map. + */ + set(...input: [...keys: K | [Join], value: V]): void { + const value = input.pop() as V; + const keys = input.join(this.separator) as Join; - // Creates the html stream - const htmlStream = renderToStream(renderLayout); + if (this.kmap[keys]) { + throw new Error(`Key ${keys} already exists in map`); + } - // Pipes it into the response - htmlStream.pipe(response); + if (this.vmap[value]) { + throw new Error(`Value ${value} already exists in map`); + } + + this.kmap[keys] = value; + this.vmap[value] = keys; + } + + /** + * Removes a key-value pair from the map. + * + * This method uses `delete` under the hood since this map was designed be a fast access + * map and not a volatile one. + * + * @param key - The components of the compound key. + * @returns A boolean indicating whether the key was removed. + */ + remove(...key: K | [Join]): boolean { + const keys = key.join(this.separator) as Join; + + if (!this.kmap[keys]) { + return false; + } - // If it's a fastify server just use - // response.type('text/html; charset=utf-8').send(htmlStream); - }) - .listen(8080, () => { - console.log('Listening to http://localhost:8080'); - }); + return delete this.vmap[this.kmap[keys]] && delete this.kmap[keys]; + } + + /** + * Retrieves the value associated with a compound key. + * + * ```ts + * const ubimap = new UbiMap<[string, string]>(); + * + * // Keys as separate arguments + * ubimap.get('key1', 'key2'); + * + * // Key in a single string + * ubimap.set('key1 key2'); + * ``` + * + * @param key - The components of the compound key. + * @returns The value associated with the key. + */ + get(...key: K | [Join]): V { + return this.kmap[key.join(this.separator) as Join]; + } + + /** + * Retrieves the compound key associated with a value. + * + * @example + * + * ```ts + * const ubimap = new UbiMap<[string, string]>(); + * + * ubimap.set('a', 'b', 'value'); + * + * console.log(ubimap.getKey('value')); // 'a b' + * ``` + * + * @param value - The value to look up. + * @returns The joined key corresponding to the value, or `undefined` if the value does + * not exist. + */ + getKey(value: V): Join | undefined { + return this.vmap[value]; + } + + /** + * Lists all keys in the map that start with the specified prefixes. + * + * @example + * + * ```ts + * const ubimap = new UbiMap<[string, string]>(); + * + * ubimap.set('a', 'b', 'value1'); + * ubimap.set('a', 'c', 'value2'); + * ubimap.set('b', 'c', 'value3'); + * + * console.log(ubimap.keys('a')); // ['a b', 'a c'] + * ``` + * + * @param prefixes - A partial tuple representing the key prefix to filter by. + * @returns An array of keys matching the prefix. + */ + keys

>(...prefixes: P): Extract, Join>[] { + const prefix = prefixes.join(this.separator); + + return Object.keys(this.kmap).filter((key) => key.startsWith(prefix)) as Extract< + Join, + Join + >[]; + } + + /** + * Lists all values in the map whose associated keys start with the specified prefixes. + * + * @example + * + * ```ts + * const ubimap = new UbiMap<[string, string]>(); + * + * ubimap.set('a', 'b', 'value1'); + * ubimap.set('a', 'c', 'value2'); + * ubimap.set('b', 'c', 'value3'); + * + * console.log(ubimap.values('a')); // ['value1', 'value2'] + * console.log(ubimap.values()); // ['value1', 'value2', 'value3'] + * ``` + * + * @param prefixes - A partial tuple representing the key prefix to filter by. + * @returns An array of values corresponding to the matching keys. + */ + values(...prefixes: PartialTuple): V[] { + const prefix = prefixes.join(this.separator); + return Object.keys(this.vmap).filter((value) => value.startsWith(prefix)) as V[]; + } + + /** Iterates over all key-value pairs in the map. */ + *[Symbol.iterator]() { + for (const key in this.kmap) { + yield [key, this.kmap[key as Join]]; + } + } +} diff --git a/index.html b/index.html new file mode 100644 index 000000000..485f31181 --- /dev/null +++ b/index.html @@ -0,0 +1,110093 @@ + +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/html/jsx.d.ts b/packages/html/jsx.d.ts index 30eb75c0f..595f0631d 100644 --- a/packages/html/jsx.d.ts +++ b/packages/html/jsx.d.ts @@ -680,6 +680,7 @@ declare namespace JSX { * * **If you intend to utilize a similar property, please opt for an alternate name.** * + * @deprecated * @see https://github.com/reactjs/rfcs/pull/107 */ key?: undefined | never; diff --git a/packages/html/suspense.d.ts b/packages/html/suspense.d.ts index 8e7256927..2a657a5d1 100644 --- a/packages/html/suspense.d.ts +++ b/packages/html/suspense.d.ts @@ -1,5 +1,5 @@ import type { Readable } from 'node:stream'; -import type { Children } from './'; +import type { Children } from './index'; declare global { /** @@ -12,7 +12,7 @@ declare global { * write the HTML, the number of running promises and if the first suspense has * already resolved. */ - requests: Map; + requests: Map; /** * This value is used (and incremented shortly after) when no requestId is provided @@ -34,6 +34,9 @@ declare global { }; } +/** A unique request identifier that can be any literal type. */ +export type Rid = number | string; + /** Everything a suspense needs to know about its request lifecycle. */ export type RequestData = { /** If the first suspense has already resolved */ @@ -62,6 +65,14 @@ export type RequestData = { */ export function Suspense(props: SuspenseProps): JSX.Element; +/** + * A component that keeps injecting html while the generator is running. + * + * The `rid` prop is the one {@linkcode renderToStream} returns, this way the suspense + * knows which request it belongs to. + */ +export function Generator(props: GeneratorProps): JSX.Element; + /** * Transforms a component tree who may contain `Suspense` components into a stream of * HTML. @@ -89,10 +100,11 @@ export function Suspense(props: SuspenseProps): JSX.Element; * id will be used. * @see {@linkcode Suspense} */ -export function renderToStream( - html: JSX.Element | ((rid: number | string) => JSX.Element), - rid?: number | string +export declare function renderToStream( + html: (rid: Rid) => JSX.Element, + rid?: Rid ): Readable; +export declare function renderToStream(html: JSX.Element, rid: Rid): Readable; /** * Joins the html base template (with possible suspense's fallbacks) with the request data @@ -122,9 +134,71 @@ export function renderToStream( */ export function resolveHtmlStream(template: JSX.Element, data: RequestData): Readable; +/** A helper function to get the request data for a given request id. */ +export function useRequestData(rid: Rid): RequestData; + +/** + * A helper function to attempt to use the error handler to recovery an async error thrown + * by a suspense or generator. + * + * @param run A unique number to identify the current template **(not request id)** + */ +export function recoverPromiseRejection( + data: RequestData, + run: number, + handler: SuspenseProps['catch'], + mode: RcInsert, + error: Error +): Promise; + +/** + * A helper function to clear the request data once everything is resolved and the stream + * needs to be cleared to avoid memory leaks. + */ +export function clearRequestData(rid: Rid, data: RequestData): void; + +/** + * Creates the html