(Init): Added shit
This commit is contained in:
5
crates/shared/deno.json
Normal file
5
crates/shared/deno.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@elly/shared",
|
||||
"version": "0.1.0",
|
||||
"exports": "./mod.ts"
|
||||
}
|
||||
106
crates/shared/mod.ts
Normal file
106
crates/shared/mod.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @elly/shared — Public API barrel.
|
||||
*
|
||||
* Both `@elly/core` and `@elly/bot` import from here. This barrel is the only
|
||||
* sanctioned cross-crate surface. Anything not re-exported is considered
|
||||
* implementation detail and may change without notice.
|
||||
*/
|
||||
|
||||
// ----- Config --------------------------------------------------------
|
||||
export {
|
||||
ApiSchema,
|
||||
BotSchema,
|
||||
ChannelsSchema,
|
||||
ColorsSchema,
|
||||
ConfigSchema,
|
||||
DatabaseSchema,
|
||||
FeaturesSchema,
|
||||
GuildSchema,
|
||||
IpcSchema,
|
||||
KvSchema,
|
||||
LimitsSchema,
|
||||
LoggingSchema,
|
||||
RolesSchema,
|
||||
} from "./src/config/schema.ts";
|
||||
export type { Config } from "./src/config/schema.ts";
|
||||
export type {
|
||||
ApiConfig,
|
||||
BotConfig,
|
||||
ChannelsConfig,
|
||||
ColorsConfig,
|
||||
ConfigInput,
|
||||
DatabaseConfig,
|
||||
FeaturesConfig,
|
||||
GuildConfig,
|
||||
IpcConfig,
|
||||
KvConfig,
|
||||
LimitsConfig,
|
||||
LoggingConfig,
|
||||
RolesConfig,
|
||||
} from "./src/config/types.ts";
|
||||
export { loadConfig, validateConfig } from "./src/config/loader.ts";
|
||||
export {
|
||||
BotEnvSchema,
|
||||
CoreEnvSchema,
|
||||
loadEnv,
|
||||
SharedEnvSchema,
|
||||
} from "./src/config/env.ts";
|
||||
export type { BotEnv, CoreEnv, SharedEnv } from "./src/config/env.ts";
|
||||
export {
|
||||
ConfigError,
|
||||
ConfigValidationError,
|
||||
EnvValidationError,
|
||||
} from "./src/config/errors.ts";
|
||||
export type { ConfigValidationIssue } from "./src/config/errors.ts";
|
||||
|
||||
// ----- Logger --------------------------------------------------------
|
||||
export { createConsoleLogger, createLogger } from "./src/logger/factory.ts";
|
||||
export { LOG_LEVEL_ORDER } from "./src/logger/types.ts";
|
||||
export type {
|
||||
LogFileOptions,
|
||||
Logger,
|
||||
LoggerOptions,
|
||||
LogLevel,
|
||||
LogRecord,
|
||||
} from "./src/logger/types.ts";
|
||||
|
||||
// ----- IPC contract --------------------------------------------------
|
||||
export {
|
||||
IPC_AUTH_HEADER,
|
||||
IPC_REQUEST_ID_HEADER,
|
||||
IpcRoutes,
|
||||
} from "./src/ipc/routes.ts";
|
||||
export type { IpcRoute } from "./src/ipc/routes.ts";
|
||||
export {
|
||||
IPC_ERROR_STATUS,
|
||||
IpcError,
|
||||
IpcErrorBodySchema,
|
||||
IpcErrorCode,
|
||||
} from "./src/ipc/errors.ts";
|
||||
export type { IpcErrorBody } from "./src/ipc/errors.ts";
|
||||
export {
|
||||
AnyDomainEventSchema,
|
||||
HeartbeatEventSchema,
|
||||
ServerReadyEventSchema,
|
||||
} from "./src/ipc/events.ts";
|
||||
export type {
|
||||
AnyDomainEvent,
|
||||
BaseDomainEvent,
|
||||
DomainEventType,
|
||||
HeartbeatEvent,
|
||||
ServerReadyEvent,
|
||||
} from "./src/ipc/events.ts";
|
||||
|
||||
// ----- Util ----------------------------------------------------------
|
||||
export {
|
||||
err,
|
||||
isErr,
|
||||
isOk,
|
||||
map,
|
||||
mapErr,
|
||||
ok,
|
||||
tryCatch,
|
||||
tryCatchAsync,
|
||||
unwrap,
|
||||
} from "./src/util/result.ts";
|
||||
export type { Err, Ok, Result } from "./src/util/result.ts";
|
||||
69
crates/shared/src/config/env.ts
Normal file
69
crates/shared/src/config/env.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Environment variable schemas and loader.
|
||||
*
|
||||
* Each crate validates only the env it actually needs:
|
||||
* - `@elly/core` → `CoreEnvSchema`
|
||||
* - `@elly/bot` → `BotEnvSchema`
|
||||
*
|
||||
* Loading is fail-fast: a missing or malformed required variable aborts
|
||||
* the process before any side-effectful work begins.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { EnvValidationError, type ConfigValidationIssue } from "./errors.ts";
|
||||
|
||||
export const SharedEnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
|
||||
});
|
||||
|
||||
export const CoreEnvSchema = SharedEnvSchema.extend({
|
||||
IPC_TOKEN: z
|
||||
.string()
|
||||
.min(16, "IPC_TOKEN must be at least 16 characters (use a long random string)"),
|
||||
});
|
||||
|
||||
export const BotEnvSchema = SharedEnvSchema.extend({
|
||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||
IPC_TOKEN: z
|
||||
.string()
|
||||
.min(16, "IPC_TOKEN must be at least 16 characters (must match the core crate)"),
|
||||
});
|
||||
|
||||
export type SharedEnv = z.infer<typeof SharedEnvSchema>;
|
||||
export type CoreEnv = z.infer<typeof CoreEnvSchema>;
|
||||
export type BotEnv = z.infer<typeof BotEnvSchema>;
|
||||
|
||||
/**
|
||||
* Load and validate environment variables against a Zod object schema.
|
||||
*
|
||||
* Only keys declared on the schema's `shape` are read; everything else in
|
||||
* `Deno.env` is ignored to prevent accidental coupling.
|
||||
*
|
||||
* @throws {EnvValidationError} If any required variable is missing or invalid.
|
||||
*/
|
||||
export function loadEnv<S extends z.ZodObject<z.ZodRawShape>>(schema: S): z.output<S> {
|
||||
const raw: Record<string, string> = {};
|
||||
for (const key of Object.keys(schema.shape)) {
|
||||
const value = Deno.env.get(key);
|
||||
if (value !== undefined) {
|
||||
raw[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const result = schema.safeParse(raw);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const issues: ConfigValidationIssue[] = result.error.issues.map((issue) => ({
|
||||
path: issue.path.length === 0 ? "<env>" : String(issue.path[0]),
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}));
|
||||
|
||||
const summary = `Environment validation failed:\n${
|
||||
issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")
|
||||
}`;
|
||||
throw new EnvValidationError(summary, issues);
|
||||
}
|
||||
43
crates/shared/src/config/errors.ts
Normal file
43
crates/shared/src/config/errors.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Error types raised by the config and env loaders.
|
||||
*
|
||||
* These are deliberately distinct from `Error` subclasses inside `@elly/core`
|
||||
* or `@elly/bot` — they cross the workspace boundary and must be importable
|
||||
* by both runtimes without dragging unrelated dependencies along.
|
||||
*/
|
||||
|
||||
export class ConfigError extends Error {
|
||||
readonly path: string;
|
||||
|
||||
constructor(message: string, path: string) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigValidationError extends ConfigError {
|
||||
readonly issues: readonly ConfigValidationIssue[];
|
||||
|
||||
constructor(message: string, path: string, issues: readonly ConfigValidationIssue[]) {
|
||||
super(message, path);
|
||||
this.name = "ConfigValidationError";
|
||||
this.issues = issues;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigValidationIssue {
|
||||
readonly path: string;
|
||||
readonly message: string;
|
||||
readonly code: string;
|
||||
}
|
||||
|
||||
export class EnvValidationError extends Error {
|
||||
readonly issues: readonly ConfigValidationIssue[];
|
||||
|
||||
constructor(message: string, issues: readonly ConfigValidationIssue[]) {
|
||||
super(message);
|
||||
this.name = "EnvValidationError";
|
||||
this.issues = issues;
|
||||
}
|
||||
}
|
||||
79
crates/shared/src/config/loader.ts
Normal file
79
crates/shared/src/config/loader.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* TOML config loader for the Elly Discord Bot.
|
||||
*
|
||||
* Loads the config file from disk, parses it as TOML, validates it against
|
||||
* `ConfigSchema`, and returns a fully-typed `Config`. Validation failures
|
||||
* throw `ConfigValidationError` with structured per-path issues so the
|
||||
* boot scripts can render a helpful error and exit with a non-zero code.
|
||||
*/
|
||||
|
||||
import { parse as parseToml } from "@std/toml";
|
||||
import type { z } from "zod";
|
||||
import { ConfigSchema, type Config } from "./schema.ts";
|
||||
import {
|
||||
ConfigError,
|
||||
ConfigValidationError,
|
||||
type ConfigValidationIssue,
|
||||
} from "./errors.ts";
|
||||
|
||||
/**
|
||||
* Read, parse, and validate the config file at `path`.
|
||||
*
|
||||
* @throws {ConfigError} If the file is missing or cannot be parsed as TOML.
|
||||
* @throws {ConfigValidationError} If the parsed object doesn't satisfy the schema.
|
||||
*/
|
||||
export async function loadConfig(path: string): Promise<Config> {
|
||||
const text = await readFile(path);
|
||||
const raw = parseTomlText(text, path);
|
||||
return validateConfig(raw, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an already-parsed object against `ConfigSchema`. Exposed so tests
|
||||
* and tooling can validate config objects produced by other sources without
|
||||
* going through the filesystem.
|
||||
*/
|
||||
export function validateConfig(input: unknown, path = "<in-memory>"): Config {
|
||||
const result = ConfigSchema.safeParse(input);
|
||||
if (result.success) return result.data;
|
||||
|
||||
const issues = zodIssuesToConfigIssues(result.error);
|
||||
const summary = `Config validation failed for ${path}:\n${
|
||||
issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")
|
||||
}`;
|
||||
throw new ConfigValidationError(summary, path, issues);
|
||||
}
|
||||
|
||||
async function readFile(path: string): Promise<string> {
|
||||
try {
|
||||
return await Deno.readTextFile(path);
|
||||
} catch (err) {
|
||||
if (err instanceof Deno.errors.NotFound) {
|
||||
throw new ConfigError(`Config file not found at ${path}`, path);
|
||||
}
|
||||
if (err instanceof Deno.errors.PermissionDenied) {
|
||||
throw new ConfigError(
|
||||
`Permission denied reading config at ${path}. Did you grant --allow-read for this path?`,
|
||||
path,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTomlText(text: string, path: string): Record<string, unknown> {
|
||||
try {
|
||||
return parseToml(text);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new ConfigError(`Failed to parse TOML at ${path}: ${reason}`, path);
|
||||
}
|
||||
}
|
||||
|
||||
function zodIssuesToConfigIssues(error: z.ZodError): ConfigValidationIssue[] {
|
||||
return error.issues.map((issue) => ({
|
||||
path: issue.path.length === 0 ? "<root>" : issue.path.join("."),
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}));
|
||||
}
|
||||
154
crates/shared/src/config/schema.ts
Normal file
154
crates/shared/src/config/schema.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Zod schema for the Elly Discord Bot's TOML configuration.
|
||||
*
|
||||
* This is the canonical contract between the operator (`config.toml`) and
|
||||
* both `@elly/core` and `@elly/bot`. Parsing happens once at boot — if it
|
||||
* fails, the process exits with a structured error.
|
||||
*
|
||||
* Type inference (`Config = z.infer<typeof ConfigSchema>`) lives in
|
||||
* `./types.ts` and is re-exported from the crate's barrel.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const DiscordId = z
|
||||
.string()
|
||||
.regex(/^\d{17,19}$/, "Must be a 17–19 digit Discord snowflake ID");
|
||||
|
||||
const NonEmpty = z.string().min(1, "Must not be empty");
|
||||
|
||||
const Color = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0, "Color must be >= 0")
|
||||
.max(0xFFFFFF, "Color must be <= 0xFFFFFF");
|
||||
|
||||
export const BotSchema = z.object({
|
||||
name: NonEmpty,
|
||||
prefix: NonEmpty,
|
||||
status: z.string().default(""),
|
||||
activity_type: z
|
||||
.enum(["playing", "streaming", "listening", "watching", "competing"])
|
||||
.default("watching"),
|
||||
owners: z
|
||||
.object({
|
||||
ids: z.array(DiscordId).default([]),
|
||||
})
|
||||
.default({ ids: [] }),
|
||||
});
|
||||
|
||||
export const DatabaseSchema = z.object({
|
||||
path: NonEmpty,
|
||||
});
|
||||
|
||||
export const KvSchema = z
|
||||
.object({
|
||||
path: z.string().default("./data/kv"),
|
||||
})
|
||||
.default({ path: "./data/kv" });
|
||||
|
||||
export const IpcSchema = z
|
||||
.object({
|
||||
host: z.string().default("127.0.0.1"),
|
||||
port: z.number().int().min(1).max(65535).default(8787),
|
||||
request_timeout_ms: z.number().int().positive().default(10_000),
|
||||
})
|
||||
.default({ host: "127.0.0.1", port: 8787, request_timeout_ms: 10_000 });
|
||||
|
||||
export const ApiSchema = z.object({
|
||||
pika_cache_ttl: z.number().int().positive().default(3_600_000),
|
||||
pika_request_timeout: z.number().int().positive().default(10_000),
|
||||
});
|
||||
|
||||
export const GuildSchema = z.object({
|
||||
id: DiscordId,
|
||||
name: NonEmpty,
|
||||
});
|
||||
|
||||
export const ChannelsSchema = z.object({
|
||||
applications: NonEmpty,
|
||||
application_logs: NonEmpty,
|
||||
suggestions: NonEmpty,
|
||||
suggestion_logs: NonEmpty,
|
||||
guild_updates: NonEmpty,
|
||||
discord_changelog: NonEmpty,
|
||||
inactivity: NonEmpty,
|
||||
development_logs: NonEmpty,
|
||||
donations: NonEmpty,
|
||||
reminders: NonEmpty,
|
||||
});
|
||||
|
||||
export const RolesSchema = z.object({
|
||||
admin: NonEmpty,
|
||||
leader: NonEmpty,
|
||||
officer: NonEmpty,
|
||||
developer: NonEmpty,
|
||||
guild_member: NonEmpty,
|
||||
champion: NonEmpty,
|
||||
away: NonEmpty,
|
||||
applications_blacklisted: NonEmpty,
|
||||
suggestions_blacklisted: NonEmpty,
|
||||
manageable: z
|
||||
.object({
|
||||
ids: z.array(DiscordId).default([]),
|
||||
})
|
||||
.default({ ids: [] }),
|
||||
});
|
||||
|
||||
export const FeaturesSchema = z.object({
|
||||
applications: z.boolean().default(true),
|
||||
suggestions: z.boolean().default(true),
|
||||
statistics: z.boolean().default(true),
|
||||
family: z.boolean().default(true),
|
||||
qotd: z.boolean().default(true),
|
||||
reminders: z.boolean().default(true),
|
||||
staff_simulator: z.boolean().default(true),
|
||||
channel_filtering: z.boolean().default(true),
|
||||
auto_moderation: z.boolean().default(false),
|
||||
welcome_system: z.boolean().default(false),
|
||||
level_system: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const LimitsSchema = z.object({
|
||||
champion_max_days: z.number().int().positive().default(366),
|
||||
away_max_days: z.number().int().positive().default(355),
|
||||
purge_max_messages: z.number().int().min(1).max(100).default(100),
|
||||
reminder_max_duration_days: z.number().int().positive().default(365),
|
||||
});
|
||||
|
||||
export const ColorsSchema = z.object({
|
||||
primary: Color.default(0x5865F2),
|
||||
success: Color.default(0x57F287),
|
||||
warning: Color.default(0xFEE75C),
|
||||
error: Color.default(0xED4245),
|
||||
info: Color.default(0x5865F2),
|
||||
});
|
||||
|
||||
export const LoggingSchema = z.object({
|
||||
level: z.enum(["debug", "info", "warn", "error", "fatal"]).default("info"),
|
||||
format: z.enum(["console", "json"]).default("console"),
|
||||
file: z.string().optional(),
|
||||
file_max_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(10 * 1024 * 1024),
|
||||
file_max_backups: z.number().int().min(0).default(5),
|
||||
});
|
||||
|
||||
export const ConfigSchema = z.object({
|
||||
bot: BotSchema,
|
||||
database: DatabaseSchema,
|
||||
kv: KvSchema,
|
||||
ipc: IpcSchema,
|
||||
api: ApiSchema,
|
||||
guild: GuildSchema,
|
||||
channels: ChannelsSchema,
|
||||
roles: RolesSchema,
|
||||
features: FeaturesSchema,
|
||||
limits: LimitsSchema,
|
||||
colors: ColorsSchema,
|
||||
logging: LoggingSchema,
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
38
crates/shared/src/config/types.ts
Normal file
38
crates/shared/src/config/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Type re-exports for consumers that only need the inferred types
|
||||
* (no runtime cost — schemas are tree-shaken out for type-only imports).
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiSchema,
|
||||
BotSchema,
|
||||
ChannelsSchema,
|
||||
ColorsSchema,
|
||||
Config,
|
||||
ConfigSchema,
|
||||
DatabaseSchema,
|
||||
FeaturesSchema,
|
||||
GuildSchema,
|
||||
IpcSchema,
|
||||
KvSchema,
|
||||
LimitsSchema,
|
||||
LoggingSchema,
|
||||
RolesSchema,
|
||||
} from "./schema.ts";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type { Config };
|
||||
export type ConfigInput = z.input<typeof ConfigSchema>;
|
||||
|
||||
export type BotConfig = z.infer<typeof BotSchema>;
|
||||
export type DatabaseConfig = z.infer<typeof DatabaseSchema>;
|
||||
export type KvConfig = z.infer<typeof KvSchema>;
|
||||
export type IpcConfig = z.infer<typeof IpcSchema>;
|
||||
export type ApiConfig = z.infer<typeof ApiSchema>;
|
||||
export type GuildConfig = z.infer<typeof GuildSchema>;
|
||||
export type ChannelsConfig = z.infer<typeof ChannelsSchema>;
|
||||
export type RolesConfig = z.infer<typeof RolesSchema>;
|
||||
export type FeaturesConfig = z.infer<typeof FeaturesSchema>;
|
||||
export type LimitsConfig = z.infer<typeof LimitsSchema>;
|
||||
export type ColorsConfig = z.infer<typeof ColorsSchema>;
|
||||
export type LoggingConfig = z.infer<typeof LoggingSchema>;
|
||||
81
crates/shared/src/ipc/errors.ts
Normal file
81
crates/shared/src/ipc/errors.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* IPC error envelope — the canonical JSON shape returned for any non-2xx
|
||||
* response from `@elly/core`. The bot's `CoreClient` parses this and
|
||||
* surfaces it as a typed error to callers.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Stable machine-readable error codes. Adding a code is a non-breaking
|
||||
* change; renaming/removing one is a breaking change.
|
||||
*/
|
||||
export const IpcErrorCode = {
|
||||
UNAUTHORIZED: "unauthorized",
|
||||
FORBIDDEN: "forbidden",
|
||||
NOT_FOUND: "not_found",
|
||||
METHOD_NOT_ALLOWED: "method_not_allowed",
|
||||
BAD_REQUEST: "bad_request",
|
||||
VALIDATION_FAILED: "validation_failed",
|
||||
CONFLICT: "conflict",
|
||||
RATE_LIMITED: "rate_limited",
|
||||
UPSTREAM_FAILURE: "upstream_failure",
|
||||
INTERNAL: "internal",
|
||||
} as const;
|
||||
|
||||
export type IpcErrorCode = typeof IpcErrorCode[keyof typeof IpcErrorCode];
|
||||
|
||||
export const IpcErrorBodySchema = z.object({
|
||||
error: z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
details: z.unknown().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type IpcErrorBody = z.infer<typeof IpcErrorBodySchema>;
|
||||
|
||||
/**
|
||||
* Map of `IpcErrorCode` -> default HTTP status code. The server overrides
|
||||
* the status per response when needed; this map exists so callers (and the
|
||||
* default error middleware) have a sensible fallback.
|
||||
*/
|
||||
export const IPC_ERROR_STATUS: Record<IpcErrorCode, number> = {
|
||||
unauthorized: 401,
|
||||
forbidden: 403,
|
||||
not_found: 404,
|
||||
method_not_allowed: 405,
|
||||
bad_request: 400,
|
||||
validation_failed: 422,
|
||||
conflict: 409,
|
||||
rate_limited: 429,
|
||||
upstream_failure: 502,
|
||||
internal: 500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Error thrown by `CoreClient` (Phase 3) when an IPC response carries an
|
||||
* `IpcErrorBody`. Exported here so both crates can `instanceof`-check.
|
||||
*/
|
||||
export class IpcError extends Error {
|
||||
readonly code: IpcErrorCode | string;
|
||||
readonly status: number;
|
||||
readonly requestId?: string;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(args: {
|
||||
code: IpcErrorCode | string;
|
||||
message: string;
|
||||
status: number;
|
||||
requestId?: string;
|
||||
details?: unknown;
|
||||
}) {
|
||||
super(args.message);
|
||||
this.name = "IpcError";
|
||||
this.code = args.code;
|
||||
this.status = args.status;
|
||||
this.requestId = args.requestId;
|
||||
this.details = args.details;
|
||||
}
|
||||
}
|
||||
71
crates/shared/src/ipc/events.ts
Normal file
71
crates/shared/src/ipc/events.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Domain event contract.
|
||||
*
|
||||
* Core publishes typed events to an in-process bus and bridges them to the
|
||||
* bot over the SSE endpoint (`IpcRoutes.EVENTS`). Each event has:
|
||||
* - `type`: a string literal discriminator (`"applications.approved"`, …)
|
||||
* - `id`: a ULID, unique per event, useful for idempotency on the consumer
|
||||
* - `timestamp`: epoch milliseconds
|
||||
* - `payload`: type-narrowed by `type`
|
||||
*
|
||||
* Phase 4 extends `AnyDomainEvent` with feature-specific events. Phase 2
|
||||
* ships only `heartbeat` and `server.ready` for SSE plumbing verification.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export interface BaseDomainEvent<TType extends string, TPayload> {
|
||||
readonly type: TType;
|
||||
readonly id: string;
|
||||
readonly timestamp: number;
|
||||
readonly payload: TPayload;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase 2 events
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export interface ServerReadyEvent
|
||||
extends BaseDomainEvent<"server.ready", { version: string; pid: number }> {}
|
||||
|
||||
export interface HeartbeatEvent
|
||||
extends BaseDomainEvent<"heartbeat", { uptimeMs: number }> {}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Discriminated union of every event the bot can receive
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export type AnyDomainEvent = ServerReadyEvent | HeartbeatEvent;
|
||||
|
||||
export type DomainEventType = AnyDomainEvent["type"];
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Runtime validation (used by the bot when parsing SSE messages)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const BaseEventShape = {
|
||||
id: z.string().min(1),
|
||||
timestamp: z.number().int().nonnegative(),
|
||||
};
|
||||
|
||||
export const ServerReadyEventSchema = z.object({
|
||||
type: z.literal("server.ready"),
|
||||
...BaseEventShape,
|
||||
payload: z.object({
|
||||
version: z.string(),
|
||||
pid: z.number().int(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const HeartbeatEventSchema = z.object({
|
||||
type: z.literal("heartbeat"),
|
||||
...BaseEventShape,
|
||||
payload: z.object({
|
||||
uptimeMs: z.number().int().nonnegative(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AnyDomainEventSchema = z.discriminatedUnion("type", [
|
||||
ServerReadyEventSchema,
|
||||
HeartbeatEventSchema,
|
||||
]);
|
||||
30
crates/shared/src/ipc/routes.ts
Normal file
30
crates/shared/src/ipc/routes.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* IPC route constants.
|
||||
*
|
||||
* Single source of truth for all HTTP paths exposed by `@elly/core`. Both
|
||||
* the server (route registration) and the bot's `CoreClient` (URL construction)
|
||||
* import from here so the two cannot drift.
|
||||
*
|
||||
* Versioned under `/v1/...` — future incompatible changes ship as `/v2/...`
|
||||
* while keeping `/v1/` alive during transitions.
|
||||
*/
|
||||
|
||||
export const IpcRoutes = {
|
||||
/** Unauthenticated liveness probe. Returns 200 if the process is alive. */
|
||||
HEALTH: "/health",
|
||||
|
||||
/** Authenticated version/diagnostic payload. */
|
||||
VERSION: "/v1/version",
|
||||
|
||||
/** Server-sent events stream of domain events for the bot to react to. */
|
||||
EVENTS: "/v1/events",
|
||||
} as const;
|
||||
|
||||
export type IpcRoute = typeof IpcRoutes[keyof typeof IpcRoutes];
|
||||
|
||||
/** Standard request header carrying the IPC bearer token. */
|
||||
export const IPC_AUTH_HEADER = "authorization";
|
||||
|
||||
/** Header for correlating a request across crates and log lines. */
|
||||
export const IPC_REQUEST_ID_HEADER = "x-request-id";
|
||||
|
||||
141
crates/shared/src/logger/factory.ts
Normal file
141
crates/shared/src/logger/factory.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Universal structured logger factory.
|
||||
*
|
||||
* Construct one logger per crate at boot and pass it through the dependency
|
||||
* container. Child loggers share their parent's sinks — call `.child()`
|
||||
* freely for per-request, per-command, or per-job context.
|
||||
*/
|
||||
|
||||
import {
|
||||
LOG_LEVEL_ORDER,
|
||||
type LogLevel,
|
||||
type Logger,
|
||||
type LoggerOptions,
|
||||
type LogRecord,
|
||||
type LogSink,
|
||||
} from "./types.ts";
|
||||
import { ConsoleSink, RotatingFileSink } from "./sinks.ts";
|
||||
|
||||
/**
|
||||
* Build a configured root logger.
|
||||
*
|
||||
* Sinks created:
|
||||
* - one `ConsoleSink` (always)
|
||||
* - one `RotatingFileSink` (only if `options.file` is provided)
|
||||
*/
|
||||
export function createLogger(options: LoggerOptions): Logger {
|
||||
const sinks: LogSink[] = [new ConsoleSink(options.format)];
|
||||
|
||||
if (options.file) {
|
||||
sinks.push(
|
||||
new RotatingFileSink(options.file.path, {
|
||||
maxBytes: options.file.maxBytes,
|
||||
maxBackups: options.file.maxBackups,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return new StructuredLogger(options.name, options.level, sinks, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper for crates that want a logger without any persistent
|
||||
* file sink (e.g. tests, ad-hoc scripts). Output is colorized console only.
|
||||
*/
|
||||
export function createConsoleLogger(name: string, level: LogLevel = "info"): Logger {
|
||||
return createLogger({ name, level, format: "console" });
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Implementation
|
||||
// =====================================================================
|
||||
|
||||
class StructuredLogger implements Logger {
|
||||
constructor(
|
||||
private readonly name: string,
|
||||
private readonly level: LogLevel,
|
||||
private readonly sinks: ReadonlyArray<LogSink>,
|
||||
private readonly context: Readonly<Record<string, unknown>>,
|
||||
) {}
|
||||
|
||||
debug(msg: string, fields?: Record<string, unknown>): void {
|
||||
this.log("debug", msg, fields);
|
||||
}
|
||||
|
||||
info(msg: string, fields?: Record<string, unknown>): void {
|
||||
this.log("info", msg, fields);
|
||||
}
|
||||
|
||||
warn(msg: string, fields?: Record<string, unknown>): void {
|
||||
this.log("warn", msg, fields);
|
||||
}
|
||||
|
||||
error(msg: string, fields?: Record<string, unknown>): void {
|
||||
this.log("error", msg, fields);
|
||||
}
|
||||
|
||||
fatal(msg: string, fields?: Record<string, unknown>): void {
|
||||
this.log("fatal", msg, fields);
|
||||
}
|
||||
|
||||
child(context: Record<string, unknown>): Logger {
|
||||
return new StructuredLogger(this.name, this.level, this.sinks, {
|
||||
...this.context,
|
||||
...context,
|
||||
});
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await Promise.all(this.sinks.map((s) => s.flush()));
|
||||
}
|
||||
|
||||
private log(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
|
||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[this.level]) return;
|
||||
|
||||
const fieldsNormalized = normalizeFields(fields);
|
||||
const record: LogRecord = {
|
||||
time: new Date().toISOString(),
|
||||
level,
|
||||
logger: this.name,
|
||||
msg,
|
||||
...this.context,
|
||||
...fieldsNormalized,
|
||||
};
|
||||
|
||||
for (const sink of this.sinks) {
|
||||
sink.write(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize special values in user-supplied fields:
|
||||
* - Errors are expanded into `{ name, message, stack, cause? }` for JSON
|
||||
* serialization. The console formatter receives the same expanded shape.
|
||||
*/
|
||||
function normalizeFields(
|
||||
fields: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!fields) return undefined;
|
||||
let mutated: Record<string, unknown> | null = null;
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v instanceof Error) {
|
||||
mutated ??= { ...fields };
|
||||
mutated[k] = serializeError(v);
|
||||
}
|
||||
}
|
||||
return mutated ?? fields;
|
||||
}
|
||||
|
||||
function serializeError(err: Error): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
};
|
||||
if (err.stack) out.stack = err.stack;
|
||||
const cause = (err as { cause?: unknown }).cause;
|
||||
if (cause !== undefined) {
|
||||
out.cause = cause instanceof Error ? serializeError(cause) : cause;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
202
crates/shared/src/logger/sinks.ts
Normal file
202
crates/shared/src/logger/sinks.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Logger sink implementations.
|
||||
*
|
||||
* - `ConsoleSink`: synchronous writes to stdout/stderr, optionally
|
||||
* ANSI-colorized for `console` format or compact JSON for `json` format.
|
||||
* - `RotatingFileSink`: asynchronous, append-only JSON lines with size-based
|
||||
* rotation. Writes are serialized through a promise queue so concurrent
|
||||
* `write()` calls cannot interleave.
|
||||
*
|
||||
* Both sinks survive transient errors (logged to stderr) — a failing log
|
||||
* sink must never crash the host process.
|
||||
*/
|
||||
|
||||
import { dirname } from "@std/path";
|
||||
import { ensureDir } from "@std/fs";
|
||||
import {
|
||||
bold,
|
||||
cyan,
|
||||
gray,
|
||||
green,
|
||||
magenta,
|
||||
red,
|
||||
yellow,
|
||||
} from "@std/fmt/colors";
|
||||
import {
|
||||
LOG_LEVEL_ORDER,
|
||||
type LogLevel,
|
||||
type LogRecord,
|
||||
type LogSink,
|
||||
} from "./types.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const LEVEL_COLOR: Record<LogLevel, (s: string) => string> = {
|
||||
debug: cyan,
|
||||
info: green,
|
||||
warn: yellow,
|
||||
error: red,
|
||||
fatal: (s) => bold(magenta(s)),
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// ConsoleSink
|
||||
// =====================================================================
|
||||
|
||||
export class ConsoleSink implements LogSink {
|
||||
constructor(private readonly format: "console" | "json") {}
|
||||
|
||||
write(record: LogRecord): void {
|
||||
const line = this.format === "json"
|
||||
? JSON.stringify(record) + "\n"
|
||||
: this.formatConsole(record) + "\n";
|
||||
|
||||
const useStderr = LOG_LEVEL_ORDER[record.level] >= LOG_LEVEL_ORDER.warn;
|
||||
const writer = useStderr ? Deno.stderr : Deno.stdout;
|
||||
try {
|
||||
writer.writeSync(encoder.encode(line));
|
||||
} catch {
|
||||
// Never propagate sink failures to user code.
|
||||
}
|
||||
}
|
||||
|
||||
flush(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private formatConsole(record: LogRecord): string {
|
||||
const { time, level, logger, msg, ...rest } = record;
|
||||
const ts = gray(time);
|
||||
const lvl = LEVEL_COLOR[level](level.toUpperCase().padEnd(5));
|
||||
const name = cyan(`[${logger}]`);
|
||||
|
||||
let fields = "";
|
||||
const keys = Object.keys(rest);
|
||||
if (keys.length > 0) {
|
||||
const parts: string[] = [];
|
||||
for (const k of keys) {
|
||||
parts.push(`${k}=${formatValue(rest[k])}`);
|
||||
}
|
||||
fields = " " + gray(parts.join(" "));
|
||||
}
|
||||
|
||||
return `${ts} ${lvl} ${name} ${msg}${fields}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null) return "null";
|
||||
if (v === undefined) return "undefined";
|
||||
switch (typeof v) {
|
||||
case "string":
|
||||
return /\s/.test(v) ? JSON.stringify(v) : v;
|
||||
case "number":
|
||||
case "boolean":
|
||||
case "bigint":
|
||||
return String(v);
|
||||
case "function":
|
||||
return "[function]";
|
||||
}
|
||||
if (v instanceof Error) {
|
||||
return `${v.name}: ${v.message}`;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// RotatingFileSink
|
||||
// =====================================================================
|
||||
|
||||
export class RotatingFileSink implements LogSink {
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private readonly maxBytes: number;
|
||||
private readonly maxBackups: number;
|
||||
private dirEnsured = false;
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
options: { maxBytes?: number; maxBackups?: number } = {},
|
||||
) {
|
||||
this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024;
|
||||
this.maxBackups = options.maxBackups ?? 5;
|
||||
}
|
||||
|
||||
write(record: LogRecord): void {
|
||||
const line = JSON.stringify(record) + "\n";
|
||||
this.queue = this.queue.then(() => this.doWrite(line)).catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
Deno.stderr.writeSync(
|
||||
encoder.encode(`[logger] RotatingFileSink write failed: ${msg}\n`),
|
||||
);
|
||||
} catch {
|
||||
// give up
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.queue;
|
||||
}
|
||||
|
||||
private async doWrite(line: string): Promise<void> {
|
||||
if (!this.dirEnsured) {
|
||||
await ensureDir(dirname(this.path));
|
||||
this.dirEnsured = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await Deno.stat(this.path);
|
||||
if (stat.size + line.length > this.maxBytes) {
|
||||
await this.rotate();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) throw err;
|
||||
}
|
||||
|
||||
await Deno.writeTextFile(this.path, line, { append: true });
|
||||
}
|
||||
|
||||
private async rotate(): Promise<void> {
|
||||
// Remove the oldest backup if it would be pushed past the retention limit.
|
||||
if (this.maxBackups > 0) {
|
||||
const oldest = `${this.path}.${this.maxBackups}`;
|
||||
try {
|
||||
await Deno.remove(oldest);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Shift .{i} -> .{i+1} for i in [maxBackups-1 .. 1].
|
||||
for (let i = this.maxBackups - 1; i >= 1; i--) {
|
||||
const src = `${this.path}.${i}`;
|
||||
const dst = `${this.path}.${i + 1}`;
|
||||
try {
|
||||
await Deno.rename(src, dst);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate the active log to .1 (only if there is at least one backup slot).
|
||||
if (this.maxBackups > 0) {
|
||||
try {
|
||||
await Deno.rename(this.path, `${this.path}.1`);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) throw err;
|
||||
}
|
||||
} else {
|
||||
// No retention — just truncate.
|
||||
try {
|
||||
await Deno.remove(this.path);
|
||||
} catch (err) {
|
||||
if (!(err instanceof Deno.errors.NotFound)) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
crates/shared/src/logger/types.ts
Normal file
76
crates/shared/src/logger/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Public type contract for the universal structured logger.
|
||||
*
|
||||
* Both `@elly/core` and `@elly/bot` consume this. Sinks (console, file)
|
||||
* are implementation details and never appear in user code.
|
||||
*/
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
|
||||
|
||||
export const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
fatal: 50,
|
||||
};
|
||||
|
||||
export interface LogRecord {
|
||||
/** ISO 8601 timestamp (always UTC). */
|
||||
readonly time: string;
|
||||
/** Severity level. */
|
||||
readonly level: LogLevel;
|
||||
/** Logger name (typically a crate identifier, e.g. `@elly/core`). */
|
||||
readonly logger: string;
|
||||
/** Human-readable message. */
|
||||
readonly msg: string;
|
||||
/** Arbitrary structured context — flattened into the record. */
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LogFileOptions {
|
||||
readonly path: string;
|
||||
readonly maxBytes?: number;
|
||||
readonly maxBackups?: number;
|
||||
}
|
||||
|
||||
export interface LoggerOptions {
|
||||
/** Logger name attached to every record. */
|
||||
readonly name: string;
|
||||
/** Minimum level to emit (records below this are silently dropped). */
|
||||
readonly level: LogLevel;
|
||||
/** Console output format. File output is always JSON. */
|
||||
readonly format: "console" | "json";
|
||||
/** Optional rotating-JSON file sink configuration. */
|
||||
readonly file?: LogFileOptions;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(msg: string, fields?: Record<string, unknown>): void;
|
||||
info(msg: string, fields?: Record<string, unknown>): void;
|
||||
warn(msg: string, fields?: Record<string, unknown>): void;
|
||||
error(msg: string, fields?: Record<string, unknown>): void;
|
||||
fatal(msg: string, fields?: Record<string, unknown>): void;
|
||||
|
||||
/**
|
||||
* Derive a child logger with extra static context merged into every record.
|
||||
* The parent's sinks are shared — child loggers do not allocate new file
|
||||
* handles or buffers.
|
||||
*/
|
||||
child(context: Record<string, unknown>): Logger;
|
||||
|
||||
/**
|
||||
* Wait for any buffered/asynchronous sink writes (e.g. the rotating file
|
||||
* sink) to flush. Call before process exit to avoid losing log lines.
|
||||
*/
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sinks are the pluggable output backends used by the logger. Internal —
|
||||
* not exported from the crate barrel.
|
||||
*/
|
||||
export interface LogSink {
|
||||
write(record: LogRecord): void;
|
||||
flush(): Promise<void>;
|
||||
}
|
||||
75
crates/shared/src/util/result.ts
Normal file
75
crates/shared/src/util/result.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Result<T, E> — a lightweight, framework-agnostic result type used across
|
||||
* `@elly/shared`, `@elly/core`, and `@elly/bot`.
|
||||
*
|
||||
* Prefer this over throwing for expected/recoverable failures crossing
|
||||
* module or IPC boundaries. Reserve exceptions for genuinely exceptional
|
||||
* conditions (programmer error, invariant violation, infrastructure failure).
|
||||
*/
|
||||
|
||||
export type Ok<T> = { readonly ok: true; readonly value: T };
|
||||
export type Err<E> = { readonly ok: false; readonly error: E };
|
||||
export type Result<T, E = Error> = Ok<T> | Err<E>;
|
||||
|
||||
export function ok<T>(value: T): Ok<T> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
export function err<E>(error: E): Err<E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
|
||||
return !result.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a Result or throw the error. Use sparingly — defeats the purpose of
|
||||
* Result in most call sites.
|
||||
*/
|
||||
export function unwrap<T, E>(result: Result<T, E>): T {
|
||||
if (result.ok) return result.value;
|
||||
throw result.error instanceof Error
|
||||
? result.error
|
||||
: new Error(`Result.unwrap on Err: ${String(result.error)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the success value of a Result without affecting the error branch.
|
||||
*/
|
||||
export function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
|
||||
return result.ok ? ok(fn(result.value)) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the error value of a Result without affecting the success branch.
|
||||
*/
|
||||
export function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
|
||||
return result.ok ? result : err(fn(result.error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a throwing synchronous function in a Result.
|
||||
*/
|
||||
export function tryCatch<T>(fn: () => T): Result<T, Error> {
|
||||
try {
|
||||
return ok(fn());
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a throwing async function in a Result.
|
||||
*/
|
||||
export async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
|
||||
try {
|
||||
return ok(await fn());
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user