aboutsummaryrefslogtreecommitdiff
path: root/deno/base
diff options
context:
space:
mode:
Diffstat (limited to 'deno/base')
-rw-r--r--deno/base/config.ts93
-rw-r--r--deno/base/cron.ts43
-rw-r--r--deno/base/date.ts6
-rw-r--r--deno/base/deno.json11
-rw-r--r--deno/base/log.ts163
-rw-r--r--deno/base/text.ts3
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());
+}