diff options
| author | Yuqian Yang <crupest@crupest.life> | 2025-06-05 22:30:51 +0800 | 
|---|---|---|
| committer | Yuqian Yang <crupest@crupest.life> | 2025-06-09 21:48:00 +0800 | 
| commit | 0b702d027973ea26d7e9618d4edc181d4cd1fc31 (patch) | |
| tree | 5f568ed7f2ff756e39e78ff928ab2f26ddaf08da /deno/base | |
| parent | 2004236ad040b7db3f7fa7e3c3edae52fd9bca13 (diff) | |
| download | crupest-0b702d027973ea26d7e9618d4edc181d4cd1fc31.tar.gz crupest-0b702d027973ea26d7e9618d4edc181d4cd1fc31.tar.bz2 crupest-0b702d027973ea26d7e9618d4edc181d4cd1fc31.zip  | |
feat(deno): move deno (mail-server) to top level.
Diffstat (limited to 'deno/base')
| -rw-r--r-- | deno/base/config.ts | 94 | ||||
| -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 | 164 | ||||
| -rw-r--r-- | deno/base/text.ts | 3 | 
6 files changed, 321 insertions, 0 deletions
diff --git a/deno/base/config.ts b/deno/base/config.ts new file mode 100644 index 0000000..98722f6 --- /dev/null +++ b/deno/base/config.ts @@ -0,0 +1,94 @@ +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..1a4942d --- /dev/null +++ b/deno/base/log.ts @@ -0,0 +1,164 @@ +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()); +}  | 
