Establish project foundation and shared utilities
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
/** An expected application failure whose message is safe to send to clients. */
|
||||
export class ApplicationError extends Error {
|
||||
/**
|
||||
* @param statusCode - HTTP-equivalent status returned to the client.
|
||||
* @param message - Client-safe error summary.
|
||||
*/
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApplicationError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './application-error.ts';
|
||||
export * from './unauthorized-error.ts';
|
||||
export * from './utils.ts';
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Authentication failure whose message is safe to return to clients. */
|
||||
export class UnauthorizedError extends Error {
|
||||
/**
|
||||
* @param message - Client-safe unauthorized summary.
|
||||
*/
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message);
|
||||
this.name = 'UnauthorizedError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { UnauthorizedError } from './unauthorized-error.ts';
|
||||
import { ApplicationError } from './application-error.ts';
|
||||
|
||||
/** Stable error payload shared by HTTP, SSE, and WebSocket. */
|
||||
export type PublicError = {
|
||||
|
||||
/** Protocol-independent status carried by every transport. */
|
||||
statusCode: number;
|
||||
|
||||
/** Client-safe summary which never exposes an unexpected exception. */
|
||||
error: string;
|
||||
|
||||
/** Structured field failures supplied only for validation errors. */
|
||||
details?: Array<{ path: string; message: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert application failures into the common public transport contract.
|
||||
*
|
||||
* @param error - Any thrown value from a route or transport boundary.
|
||||
* @returns A sanitized error payload safe to encode on the wire.
|
||||
*/
|
||||
export const normalizePublicError = (error: unknown): PublicError => {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
error: 'Validation Error',
|
||||
details: error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return { statusCode: 401, error: error.message };
|
||||
}
|
||||
|
||||
if (error instanceof ApplicationError) {
|
||||
return { statusCode: error.statusCode, error: error.message };
|
||||
}
|
||||
|
||||
// Unknown exceptions are logged by adapters, but their messages stay private.
|
||||
return { statusCode: 500, error: 'Internal Server Error' };
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dotenv/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The configuration schema for the server.
|
||||
*/
|
||||
const configSchema = z.object({
|
||||
|
||||
/**
|
||||
* The database configuration.
|
||||
*/
|
||||
database: z.object({
|
||||
path: z.string().default('data.db'),
|
||||
}),
|
||||
|
||||
/**
|
||||
* The server configuration.
|
||||
*/
|
||||
server: z.object({
|
||||
port: z.coerce.number().int()
|
||||
.positive()
|
||||
.default(3000),
|
||||
host: z.string().default('0.0.0.0'),
|
||||
|
||||
/** Maximum encoded HTTP body or WebSocket message size in bytes. */
|
||||
maxRequestBodyBytes: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(1024 * 1024),
|
||||
cors: z
|
||||
.object({
|
||||
origin: z.string().default('*'),
|
||||
methods: z.array(z.string()).default([ 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS' ]),
|
||||
allowedHeaders: z.array(z.string()).default([ 'Content-Type', 'cache-control', 'X-Timestamp', 'X-PublicKey', 'X-Signature' ]),
|
||||
})
|
||||
.partial()
|
||||
.prefault({}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* The authentication configuration.
|
||||
*/
|
||||
auth: z
|
||||
.object({
|
||||
timestampWindowMs: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(5 * 60 * 1000),
|
||||
})
|
||||
.prefault({}),
|
||||
});
|
||||
|
||||
/** Raw configuration object accepted before Zod parsing. */
|
||||
type ConfigInput = z.input<typeof configSchema>;
|
||||
|
||||
/** Fully parsed and defaulted configuration shape. */
|
||||
type ConfigSchema = z.output<typeof configSchema>;
|
||||
|
||||
/**
|
||||
* Typed, validated server configuration loaded from environment or objects.
|
||||
*/
|
||||
export class Config {
|
||||
/**
|
||||
* Creates a new Config from the environment variables.
|
||||
* @returns The Config.
|
||||
*/
|
||||
static fromEnv(): Config {
|
||||
return this.from({
|
||||
database: {
|
||||
path: process.env.DATABASE_PATH,
|
||||
},
|
||||
server: {
|
||||
port: process.env.SERVER_PORT,
|
||||
maxRequestBodyBytes: process.env.SERVER_MAX_REQUEST_BODY_BYTES,
|
||||
host: process.env.SERVER_HOST,
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN,
|
||||
methods: process.env.CORS_METHODS?.split(','),
|
||||
allowedHeaders: process.env.CORS_ALLOWED_HEADERS?.split(','),
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
timestampWindowMs: process.env.AUTH_TIMESTAMP_WINDOW_MS ? Number(process.env.AUTH_TIMESTAMP_WINDOW_MS) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Config from a configuration object.
|
||||
* @param config - The configuration object.
|
||||
* @returns The Config.
|
||||
*/
|
||||
static from(config: ConfigInput): Config {
|
||||
return new Config(configSchema.parse(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the database configuration.
|
||||
* @returns The database configuration.
|
||||
*/
|
||||
public get database(): Readonly<ConfigSchema['database']> {
|
||||
return this.config.database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the server configuration.
|
||||
* @returns The server configuration.
|
||||
*/
|
||||
public get server(): Readonly<ConfigSchema['server']> {
|
||||
return this.config.server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the authentication configuration.
|
||||
* @returns The authentication configuration.
|
||||
*/
|
||||
public get auth(): Readonly<ConfigSchema['auth']> {
|
||||
return this.config.auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param config - Parsed configuration produced by the Zod schema.
|
||||
*/
|
||||
private constructor(private readonly config: ConfigSchema) {}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Debug, { type Debugger } from 'debug';
|
||||
|
||||
type LogHandler = {
|
||||
(...args: Parameters<Debugger>): void;
|
||||
extend: (namespace: string) => LogHandler;
|
||||
};
|
||||
|
||||
/**
|
||||
* Declares that Logger instances may also be invoked as functions.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
export interface Logger {
|
||||
(...args: unknown[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger class, similar to 'debug' library but you can call `instanceof Logger` to check if a value is a Logger instance.
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
export class Logger {
|
||||
public readonly namespace!: string;
|
||||
|
||||
private readonly handler!: LogHandler;
|
||||
|
||||
public constructor(namespace: string, handler: LogHandler = Debug(namespace)) {
|
||||
/**
|
||||
* I'm going to be honest, this file is somewhat an experiment.
|
||||
* The logger from 'debug' is a fancy function with methods on it.
|
||||
* I wanted to extend that functionality to support 'extend' and also determine whether the object is a Logger instance.
|
||||
* This makes it trivial to perform a type check on the logger, since its no longer just a function. But, I wanted to keep the exact same API
|
||||
* as debug, so this uses gross, disgusting, blasphemous prototype methods to assign a function onto this class prototype.
|
||||
*/
|
||||
|
||||
// Make a function that just calls the 'debug' function with the given arguments
|
||||
const logger = ((...args: Parameters<Debugger>): void => {
|
||||
handler(...args);
|
||||
}) as Logger;
|
||||
|
||||
// Mutate the logger function to inherit from this class.
|
||||
// This allows us to use the 'instanceof' operator to check if the object is a Logger instance.
|
||||
Object.setPrototypeOf(logger, new.target.prototype);
|
||||
|
||||
// Add the namespace and the handler to the 'logger' function we defined above
|
||||
// Basically, we are combining this Class with the 'logger' function that we created above.
|
||||
Object.defineProperties(logger, {
|
||||
namespace: {
|
||||
value: namespace,
|
||||
enumerable: true,
|
||||
},
|
||||
handler: {
|
||||
value: handler,
|
||||
},
|
||||
});
|
||||
|
||||
// Instead of returning the class, we return the 'logger' function we created above.
|
||||
return logger;
|
||||
}
|
||||
|
||||
public extend(childNamespace: string): Logger {
|
||||
return new Logger(`${this.namespace}:${childNamespace}`, this.handler.extend(childNamespace));
|
||||
}
|
||||
|
||||
static isLogger(value: unknown): value is Logger {
|
||||
return value instanceof Logger;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user