Quellcode durchsuchen

feat: server foundation

Thomas Zhang vor 3 Monaten
Ursprung
Commit
c3d71c260c
6 geänderte Dateien mit 250 neuen und 2 gelöschten Zeilen
  1. 9 2
      src/app.d.ts
  2. 59 0
      src/hooks.server.ts
  3. 22 0
      src/lib/server/context.ts
  4. 122 0
      src/lib/server/errors.ts
  5. 17 0
      src/lib/server/index.ts
  6. 21 0
      src/lib/server/server.ts

+ 9 - 2
src/app.d.ts

@@ -2,8 +2,15 @@
 // for information about these interfaces
 declare global {
 	namespace App {
-		// interface Error {}
-		// interface Locals {}
+		interface Error {
+			/** Machine-readable error code, e.g. 'NOT_FOUND', 'VALIDATION_ERROR'. */
+			code: string;
+			/** Human-readable message safe to display to the user. */
+			message: string;
+		}
+		interface Locals {
+			requestId: string;
+		}
 		// interface PageData {}
 		// interface PageState {}
 		// interface Platform {}

+ 59 - 0
src/hooks.server.ts

@@ -0,0 +1,59 @@
+import crypto from 'node:crypto';
+
+import type { Handle, HandleServerError } from '@sveltejs/kit';
+
+import {
+	isAppError,
+	logger as rootLogger,
+	runWithContext,
+	server,
+	serverLogger as log,
+} from '$lib/server';
+
+const ready = server.init();
+
+const shutdown = async (signal: string) => {
+	log.info(`[${signal}] Shutting down...`);
+	await server.shutdown();
+	rootLogger.close();
+	process.exit(0);
+};
+
+process.once('SIGTERM', () => shutdown('SIGTERM'));
+process.once('SIGINT', () => shutdown('SIGINT'));
+
+const handle: Handle = async ({ event, resolve: resolveRequest }) => {
+	await ready;
+
+	const requestId = crypto.randomUUID();
+	event.locals.requestId = requestId;
+
+	return runWithContext({ requestId }, async () => {
+		return resolveRequest(event);
+	});
+};
+
+/**
+ * Catches any error that escapes a load function, action, or endpoint.
+ * AppErrors are surfaced with their code and message; unexpected errors
+ * are logged and replaced with a generic message so internals are not leaked.
+ */
+const handleError: HandleServerError = ({ error, event }) => {
+	const requestId = event.locals.requestId;
+
+	if (isAppError(error)) {
+		log.warn('Application error', {
+			code: error.code,
+			status: error.statusCode,
+			message: error.message,
+			path: event.url.pathname,
+			requestId,
+		});
+		return { code: error.code, message: error.message };
+	}
+
+	log.error('Unexpected server error', { err: error, path: event.url.pathname, requestId });
+	return { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred.' };
+};
+
+export { handle, handleError };

+ 22 - 0
src/lib/server/context.ts

@@ -0,0 +1,22 @@
+/**
+ * Request-scoped async context.
+ *
+ * Uses Node.js AsyncLocalStorage to propagate request metadata (request ID, etc.)
+ * through the entire async call chain without passing it explicitly through every
+ * function. The logger reads from this store to attach `requestId` to every log
+ * entry automatically.
+ */
+import { AsyncLocalStorage } from 'node:async_hooks';
+
+type RequestContext = {
+	requestId: string;
+};
+
+const storage = new AsyncLocalStorage<RequestContext>();
+
+const getContext = (): RequestContext | undefined => storage.getStore();
+
+const runWithContext = <T>(context: RequestContext, fn: () => T): T => storage.run(context, fn);
+
+export type { RequestContext };
+export { getContext, runWithContext };

+ 122 - 0
src/lib/server/errors.ts

@@ -0,0 +1,122 @@
+/**
+ * Application error types and utilities.
+ *
+ * All domain errors extend `AppError`, which carries a machine-readable `code`,
+ * an HTTP `statusCode`, and an optional `details` payload for structured context.
+ *
+ * Infrastructure errors (K8s, Gogs) should always be wrapped via `wrapInfraError`
+ * so that internal details are logged but never leaked to clients.
+ */
+
+// --- Base ---
+
+class AppError extends Error {
+	readonly code: string;
+	readonly statusCode: number;
+	readonly details?: Record<string, unknown>;
+
+	constructor(
+		code: string,
+		message: string,
+		statusCode: number,
+		details?: Record<string, unknown>,
+	) {
+		super(message);
+		this.name = 'AppError';
+		this.code = code;
+		this.statusCode = statusCode;
+		this.details = details;
+	}
+}
+
+class NotFoundError extends AppError {
+	constructor(resource: string, id?: string) {
+		super('NOT_FOUND', id ? `${resource} '${id}' not found` : `${resource} not found`, 404);
+		this.name = 'NotFoundError';
+	}
+}
+
+class UnauthorizedError extends AppError {
+	constructor(message = 'Authentication required') {
+		super('UNAUTHORIZED', message, 401);
+		this.name = 'UnauthorizedError';
+	}
+}
+
+class ForbiddenError extends AppError {
+	constructor(message = 'Insufficient permissions') {
+		super('FORBIDDEN', message, 403);
+		this.name = 'ForbiddenError';
+	}
+}
+
+class ValidationError extends AppError {
+	constructor(message: string, fields?: Record<string, string>) {
+		super('VALIDATION_ERROR', message, 400, fields ? { fields } : undefined);
+		this.name = 'ValidationError';
+	}
+}
+
+class ConflictError extends AppError {
+	constructor(resource: string, id: string) {
+		super('CONFLICT', `${resource} '${id}' already exists`, 409);
+		this.name = 'ConflictError';
+	}
+}
+
+/**
+ * Wraps a raw error from an external system (K8s, Gogs) into an AppError.
+ * The original error is preserved as `cause` for logging but the exposed
+ * message is sanitized to avoid leaking internal details to clients.
+ */
+class InfrastructureError extends AppError {
+	constructor(system: string, operation: string, cause: unknown) {
+		super('INFRASTRUCTURE_ERROR', `${system} error during ${operation}`, 502, {
+			system,
+			operation,
+		});
+		this.name = 'InfrastructureError';
+		this.cause = cause;
+	}
+}
+
+/**
+ * Thrown during server init when a required config key is absent.
+ * Not an HTTP error — causes the process to exit (OrDie behavior).
+ */
+class ConfigError extends Error {
+	readonly key: string;
+
+	constructor(key: string) {
+		super(`Required config key '${key}' is not set`);
+		this.name = 'ConfigError';
+		this.key = key;
+	}
+}
+
+/** Throw a ConfigError if the value is absent. Use in Server.init() for required config. */
+const requireConfig = (key: string, value: string | undefined): string => {
+	if (!value) throw new ConfigError(key);
+	return value;
+};
+
+/** Wrap an unknown infrastructure error, preserving it as `cause`. */
+const wrapInfraError = (system: string, operation: string, cause: unknown): InfrastructureError =>
+	new InfrastructureError(system, operation, cause);
+
+/** Type guard — true if `err` is an AppError (and subclasses). */
+const isAppError = (err: unknown): err is AppError => err instanceof AppError;
+
+export type { AppError };
+export {
+	ConfigError,
+	ConflictError,
+	ForbiddenError,
+	InfrastructureError,
+	isAppError,
+	NotFoundError,
+	requireConfig,
+	UnauthorizedError,
+	ValidationError,
+	wrapInfraError,
+};

+ 17 - 0
src/lib/server/index.ts

@@ -0,0 +1,17 @@
+export type { RequestContext } from './context';
+export { getContext, runWithContext } from './context';
+export type { AppError } from './errors';
+export {
+	ConfigError,
+	ConflictError,
+	ForbiddenError,
+	InfrastructureError,
+	isAppError,
+	NotFoundError,
+	requireConfig,
+	UnauthorizedError,
+	ValidationError,
+	wrapInfraError,
+} from './errors';
+export * from './logger';
+export { Server, server } from './server';

+ 21 - 0
src/lib/server/server.ts

@@ -0,0 +1,21 @@
+import { serverLogger as log } from '$lib/server/logger';
+
+class Server {
+	// Guards against double-init in HMR / test environments.
+	#initialized = false;
+
+	async init(): Promise<void> {
+		if (this.#initialized) return;
+		this.#initialized = true;
+		log.info('Server initializing');
+
+		log.info('Server ready');
+	}
+
+	async shutdown(): Promise<void> {}
+}
+
+// Singleton shared across hooks and route handlers.
+const server = new Server();
+
+export { Server, server };