diff options
Diffstat (limited to 'deno/base')
-rw-r--r-- | deno/base/config.ts | 93 | ||||
-rw-r--r-- | deno/base/cron.ts | 43 | ||||
-rw-r--r-- | deno/base/date.ts | 6 | ||||
-rw-r--r-- | deno/base/deno.json | 11 | ||||
-rw-r--r-- | deno/base/log.ts | 163 | ||||
-rw-r--r-- | deno/base/text.ts | 3 |
6 files changed, 319 insertions, 0 deletions
diff --git a/deno/base/config.ts b/deno/base/config.ts new file mode 100644 index 0000000..8fce1d8 --- /dev/null +++ b/deno/base/config.ts @@ -0,0 +1,93 @@ +import { camelCaseToKebabCase } from "./text.ts"; + +export interface ConfigDefinitionItem { + readonly description: string; + readonly default?: string; + readonly secret?: boolean; +} + +interface ConfigMapItem extends ConfigDefinitionItem { + readonly env: string; + value?: string; +} + +export type ConfigDefinition<K extends string = string> = Record< + K, + ConfigDefinitionItem +>; +type ConfigMap<K extends string = string> = Record<K, ConfigMapItem>; + +export class ConfigProvider<K extends string> { + readonly #prefix: string; + readonly #map: ConfigMap<K>; + + constructor(prefix: string, ...definitions: Partial<ConfigDefinition<K>>[]) { + this.#prefix = prefix; + + const map: ConfigMap = {}; + for (const definition of definitions) { + for (const [key, def] of Object.entries(definition as ConfigDefinition)) { + map[key] = { + ...def, + env: `${this.#prefix}-${camelCaseToKebabCase(key as string)}` + .replaceAll("-", "_") + .toUpperCase(), + }; + } + } + this.#map = map as ConfigMap<K>; + } + + resolveFromEnv(options?: { keys?: K[] }) { + const keys = options?.keys ?? Object.keys(this.#map); + for (const key of keys) { + const { env, description, default: _default } = this.#map[key as K]; + const value = Deno.env.get(env) ?? _default; + if (value == null) { + throw new Error(`Required env ${env} (${description}) is not set.`); + } + this.#map[key as K].value = value; + } + } + + get(key: K): string { + if (!(key in this.#map)) { + throw new Error(`Unknown config key ${key as string}.`); + } + if (this.#map[key].value == null) { + this.resolveFromEnv({ keys: [key] }); + } + return this.#map[key].value!; + } + + set(key: K, value: string) { + if (!(key in this.#map)) { + throw new Error(`Unknown config key ${key as string}.`); + } + this.#map[key].value = value; + } + + getInt(key: K): number { + return Number(this.get(key)); + } + + getList(key: K, separator: string = ","): string[] { + const value = this.get(key); + if (value.length === 0) return []; + return value.split(separator); + } + + [Symbol.for("Deno.customInspect")]() { + const getValueString = (item: ConfigMapItem): string => { + if (item.value == null) return "(unresolved)"; + if (item.secret === true) return "***"; + return item.value; + }; + + return Object.entries(this.#map as ConfigMap) + .map( + ([key, item]) => `${key} [env: ${item.env}]: ${getValueString(item)}`, + ) + .join("\n"); + } +} diff --git a/deno/base/cron.ts b/deno/base/cron.ts new file mode 100644 index 0000000..bf0a0be --- /dev/null +++ b/deno/base/cron.ts @@ -0,0 +1,43 @@ +export type CronCallback = (task: CronTask) => Promise<void>; + +export interface CronTaskConfig { + readonly name: string; + readonly interval: number; + readonly callback: CronCallback; + readonly startNow?: boolean; +} + +export class CronTask { + #timerTag: number | null = null; + + constructor(public readonly config: CronTaskConfig) { + if (config.interval <= 0) { + throw new Error("Cron task interval must be positive."); + } + + if (config.startNow === true) { + this.start(); + } + } + + get running(): boolean { + return this.#timerTag != null; + } + + start() { + if (this.#timerTag == null) { + this.#timerTag = setInterval( + this.config.callback, + this.config.interval, + this, + ); + } + } + + stop() { + if (this.#timerTag != null) { + clearInterval(this.#timerTag); + this.#timerTag = null; + } + } +} diff --git a/deno/base/date.ts b/deno/base/date.ts new file mode 100644 index 0000000..e65691e --- /dev/null +++ b/deno/base/date.ts @@ -0,0 +1,6 @@ +export function toFileNameString(date: Date, dateOnly?: boolean): string { + const str = date.toISOString(); + return dateOnly === true + ? str.slice(0, str.indexOf("T")) + : str.replaceAll(/:|\./g, "-"); +} diff --git a/deno/base/deno.json b/deno/base/deno.json new file mode 100644 index 0000000..2c2d550 --- /dev/null +++ b/deno/base/deno.json @@ -0,0 +1,11 @@ +{ + "name": "@crupest/base", + "version": "0.1.0", + "exports": { + "./config": "./config.ts", + "./cron": "./cron.ts", + "./date": "./date.ts", + "./text": "./text.ts", + "./log": "./log.ts" + } +} diff --git a/deno/base/log.ts b/deno/base/log.ts new file mode 100644 index 0000000..d0a5b80 --- /dev/null +++ b/deno/base/log.ts @@ -0,0 +1,163 @@ +import { join } from "@std/path"; + +import { toFileNameString } from "./date.ts"; + +export type LogLevel = "error" | "warn" | "info"; + +export interface LogEntry { + content: [unknown, ...unknown[]]; + level?: LogLevel; + cause?: unknown; +} + +export interface LogEntryBuilder { + withLevel(level: LogLevel): LogEntryBuilder; + withCause(cause: unknown): LogEntryBuilder; + setError(error: boolean): LogEntryBuilder; + write(): void; +} + +export interface ExternalLogStream extends Disposable { + stream: WritableStream; +} + +export class Logger { + #indentSize = 2; + #externalLogDir?: string; + + #contextStack: { depth: number; level: LogLevel }[] = [ + { depth: 0, level: "info" }, + ]; + + get #context() { + return this.#contextStack.at(-1)!; + } + + get indentSize() { + return this.#indentSize; + } + + set indentSize(value: number) { + this.#indentSize = value; + } + + get externalLogDir() { + return this.#externalLogDir; + } + + set externalLogDir(value: string | undefined) { + this.#externalLogDir = value; + if (value != null) { + Deno.mkdirSync(value, { + recursive: true, + }); + } + } + + write(entry: LogEntry): void { + const { content, level, cause } = entry; + const [message, ...rest] = content; + console[level ?? this.#context.level]( + " ".repeat(this.#indentSize * this.#context.depth) + String(message), + ...(cause != null ? [cause, ...rest] : rest), + ); + } + + push(entry: LogEntry): Disposable { + this.write(entry); + this.#contextStack.push({ + depth: this.#context.depth + 1, + level: entry.level ?? this.#context.level, + }); + return { + [Symbol.dispose]: () => { + this.#contextStack.pop(); + }, + }; + } + + info(message: unknown, ...args: unknown[]) { + this.write({ level: "info", content: [message, ...args] }); + } + + warn(message: unknown, ...args: unknown[]) { + this.write({ level: "warn", content: [message, ...args] }); + } + + error(message: unknown, ...args: unknown[]) { + this.write({ level: "error", content: [message, ...args] }); + } + + builder(message: unknown, ...args: unknown[]): LogEntryBuilder { + const entry: LogEntry = { + content: [message, ...args], + level: "info", + cause: undefined, + }; + const builder: LogEntryBuilder = { + withCause: (cause) => { + entry.cause = cause; + return builder; + }, + withLevel: (level) => { + entry.level = level; + return builder; + }, + setError: (error) => { + if (error) entry.level = "error"; + return builder; + }, + write: () => { + this.write(entry); + }, + }; + return builder; + } + + async createExternalLogStream( + name: string, + options?: { + noTime?: boolean; + }, + ): Promise<ExternalLogStream> { + if (name.includes("/")) { + throw new Error(`External log stream's name (${name}) contains '/'.`); + } + if (this.#externalLogDir == null) { + throw new Error("External log directory is not set."); + } + + const logPath = join( + this.#externalLogDir, + options?.noTime === true + ? name + : `${name}-${toFileNameString(new Date())}`, + ); + + const file = await Deno.open(logPath, { + read: false, + write: true, + append: true, + create: true, + }); + return { + stream: file.writable, + [Symbol.dispose]: file[Symbol.dispose].bind(file), + }; + } + + async createExternalLogStreamsForProgram( + program: string, + ): Promise<{ stdout: WritableStream; stderr: WritableStream } & Disposable> { + const stdout = await this.createExternalLogStream(`${program}-stdout`); + const stderr = await this.createExternalLogStream(`${program}-stderr`); + return { + stdout: stdout.stream, + stderr: stderr.stream, + [Symbol.dispose]: () => { + stdout[Symbol.dispose](); + stderr[Symbol.dispose](); + }, + }; + } +} diff --git a/deno/base/text.ts b/deno/base/text.ts new file mode 100644 index 0000000..f3e4020 --- /dev/null +++ b/deno/base/text.ts @@ -0,0 +1,3 @@ +export function camelCaseToKebabCase(str: string): string { + return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()); +} |