Template
1
0

feat: modular domain driven boilerplate

This commit is contained in:
2025-09-22 01:29:55 +02:00
parent 2433f59d1a
commit 9be3230c84
160 changed files with 2468 additions and 1525 deletions

View File

@@ -11,6 +11,7 @@ import type { RouteMethod } from "./route.ts";
const ServerErrorResponseSchema = z.object({
error: z.object({
code: z.any(),
status: z.number(),
message: z.string(),
data: z.any().optional(),
@@ -51,9 +52,10 @@ export type RelayAdapter = {
/**
* Send a request to the configured relay url.
*
* @param input - Request input parameters.
* @param input - Request input parameters.
* @param publicKey - Key to encrypt the payload with.
*/
send(input: RelayInput): Promise<RelayResponse>;
send(input: RelayInput, publicKey?: string): Promise<RelayResponse>;
/**
* Sends a fetch request using the given options and returns a
@@ -75,10 +77,12 @@ export type RelayInput = {
export type RelayResponse<TData = unknown, TError = ServerErrorType | ServerErrorResponse["error"]> =
| {
result: "success";
headers: Headers;
data: TData;
}
| {
result: "error";
headers: Headers;
error: TError;
};

View File

@@ -110,7 +110,7 @@ function getRouteFn(route: Route, { adapter }: Config) {
// ### Fetch
const response = await adapter.send(input);
const response = await adapter.send(input, route.state.crypto?.publicKey);
if ("data" in response && route.state.response !== undefined) {
response.data = route.state.response.parse(response.data);

View File

@@ -0,0 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ServerContext {}
export const context: ServerContext = {} as any;

View File

@@ -1,6 +1,8 @@
import type { $ZodErrorTree } from "zod/v4/core";
import { ZodError } from "zod";
export abstract class ServerError<TData = unknown> extends Error {
abstract readonly code: string;
constructor(
message: string,
readonly status: number,
@@ -12,40 +14,40 @@ export abstract class ServerError<TData = unknown> extends Error {
/**
* Converts a server delivered JSON error to its native instance.
*
* @param value - Error JSON.
* @param error - Error JSON.
*/
static fromJSON(value: ServerErrorJSON): ServerErrorType {
switch (value.status) {
case 400:
return new BadRequestError(value.message, value.data);
case 401:
return new UnauthorizedError(value.message, value.data);
case 403:
return new ForbiddenError(value.message, value.data);
case 404:
return new NotFoundError(value.message, value.data);
case 405:
return new MethodNotAllowedError(value.message, value.data);
case 406:
return new NotAcceptableError(value.message, value.data);
case 409:
return new ConflictError(value.message, value.data);
case 410:
return new GoneError(value.message, value.data);
case 415:
return new UnsupportedMediaTypeError(value.message, value.data);
case 422:
return new UnprocessableContentError(value.message, value.data);
case 432:
return new ZodValidationError(value.message, value.data);
case 500:
return new InternalServerError(value.message, value.data);
case 501:
return new NotImplementedError(value.message, value.data);
case 503:
return new ServiceUnavailableError(value.message, value.data);
static fromJSON(error: ServerErrorJSON): ServerErrorType {
switch (error.code) {
case "BAD_REQUEST":
return new BadRequestError(error.message, error.data);
case "UNAUTHORIZED":
return new UnauthorizedError(error.message, error.data);
case "FORBIDDEN":
return new ForbiddenError(error.message, error.data);
case "NOT_FOUND":
return new NotFoundError(error.message, error.data);
case "METHOD_NOT_ALLOWED":
return new MethodNotAllowedError(error.message, error.data);
case "NOT_ACCEPTABLE":
return new NotAcceptableError(error.message, error.data);
case "CONFLICT":
return new ConflictError(error.message, error.data);
case "GONE":
return new GoneError(error.message, error.data);
case "UNSUPPORTED_MEDIA_TYPE":
return new UnsupportedMediaTypeError(error.message, error.data);
case "UNPROCESSABLE_CONTENT":
return new UnprocessableContentError(error.message, error.data);
case "VALIDATION":
return new ValidationError(error.message, error.data);
case "INTERNAL_SERVER":
return new InternalServerError(error.message, error.data);
case "NOT_IMPLEMENTED":
return new NotImplementedError(error.message, error.data);
case "SERVICE_UNAVAILABLE":
return new ServiceUnavailableError(error.message, error.data);
default:
return new InternalServerError(value.message, value.data);
return new InternalServerError(error.message, error.data);
}
}
@@ -54,7 +56,7 @@ export abstract class ServerError<TData = unknown> extends Error {
*/
toJSON(): ServerErrorJSON {
return {
type: "relay",
code: this.code as ServerErrorJSON["code"],
status: this.status,
message: this.message,
data: this.data,
@@ -63,6 +65,8 @@ export abstract class ServerError<TData = unknown> extends Error {
}
export class BadRequestError<TData = unknown> extends ServerError<TData> {
readonly code = "BAD_REQUEST";
/**
* Instantiate a new BadRequestError.
*
@@ -70,6 +74,7 @@ export class BadRequestError<TData = unknown> extends ServerError<TData> {
* cannot or will not process the request due to something that is perceived to
* be a client error.
*
* @param message - the message that describes the error. Default: "Bad Request".
* @param data - Optional data to send with the error.
*/
constructor(message = "Bad Request", data?: TData) {
@@ -78,6 +83,8 @@ export class BadRequestError<TData = unknown> extends ServerError<TData> {
}
export class UnauthorizedError<TData = unknown> extends ServerError<TData> {
readonly code = "UNAUTHORIZED";
/**
* Instantiate a new UnauthorizedError.
*
@@ -104,6 +111,8 @@ export class UnauthorizedError<TData = unknown> extends ServerError<TData> {
}
export class ForbiddenError<TData = unknown> extends ServerError<TData> {
readonly code = "FORBIDDEN";
/**
* Instantiate a new ForbiddenError.
*
@@ -125,6 +134,8 @@ export class ForbiddenError<TData = unknown> extends ServerError<TData> {
}
export class NotFoundError<TData = unknown> extends ServerError<TData> {
readonly code = "NOT_FOUND";
/**
* Instantiate a new NotFoundError.
*
@@ -147,6 +158,8 @@ export class NotFoundError<TData = unknown> extends ServerError<TData> {
}
export class MethodNotAllowedError<TData = unknown> extends ServerError<TData> {
readonly code = "METHOD_NOT_ALLOWED";
/**
* Instantiate a new MethodNotAllowedError.
*
@@ -164,6 +177,8 @@ export class MethodNotAllowedError<TData = unknown> extends ServerError<TData> {
}
export class NotAcceptableError<TData = unknown> extends ServerError<TData> {
readonly code = "NOT_ACCEPTABLE";
/**
* Instantiate a new NotAcceptableError.
*
@@ -181,6 +196,8 @@ export class NotAcceptableError<TData = unknown> extends ServerError<TData> {
}
export class ConflictError<TData = unknown> extends ServerError<TData> {
readonly code = "CONFLICT";
/**
* Instantiate a new ConflictError.
*
@@ -202,6 +219,8 @@ export class ConflictError<TData = unknown> extends ServerError<TData> {
}
export class GoneError<TData = unknown> extends ServerError<TData> {
readonly code = "GONE";
/**
* Instantiate a new GoneError.
*
@@ -225,6 +244,8 @@ export class GoneError<TData = unknown> extends ServerError<TData> {
}
export class UnsupportedMediaTypeError<TData = unknown> extends ServerError<TData> {
readonly code = "UNSUPPORTED_MEDIA_TYPE";
/**
* Instantiate a new UnsupportedMediaTypeError.
*
@@ -242,6 +263,8 @@ export class UnsupportedMediaTypeError<TData = unknown> extends ServerError<TDat
}
export class UnprocessableContentError<TData = unknown> extends ServerError<TData> {
readonly code = "UNPROCESSABLE_CONTENT";
/**
* Instantiate a new UnprocessableContentError.
*
@@ -263,22 +286,49 @@ export class UnprocessableContentError<TData = unknown> extends ServerError<TDat
}
}
export class ZodValidationError<TData extends $ZodErrorTree<any, any>> extends ServerError<TData> {
export class ValidationError extends ServerError<ValidationErrorData> {
readonly code = "VALIDATION";
/**
* Instantiate a new ZodValidationError.
* Instantiate a new ValidationError.
*
* This indicates that the server understood the request body, but the structure
* failed validation against the expected schema.
* This indicates that the server understood the request, but the content
* failed semantic validation against the expected schema.
*
* @param message - Optional message to send with the error. Default: "Unprocessable Content".
* @param data - ZodError instance to pass through.
* @param message - Optional message to send with the error. Default: "Validation Failed".
* @param data - Data with validation failure details.
*/
constructor(message: string, data: TData) {
super(message, 432, data);
constructor(message = "Validation Failed", data: ValidationErrorData) {
super(message, 422, data);
}
/**
* Instantiate a new ValidationError.
*
* This indicates that the server understood the request, but the content
* failed semantic validation against the expected schema.
*
* @param zodError - The original ZodError instance.
* @param source - The source of the validation error.
* @param message - Optional override for the main error message.
*/
static fromZod(zodError: ZodError, source: ErrorSource, message?: string) {
return new ValidationError(message, {
details: zodError.issues.map((issue) => {
return {
source: source,
code: issue.code,
field: issue.path.join("."),
message: issue.message,
};
}),
});
}
}
export class InternalServerError<TData = unknown> extends ServerError<TData> {
readonly code = "INTERNAL_SERVER";
/**
* Instantiate a new InternalServerError.
*
@@ -302,6 +352,8 @@ export class InternalServerError<TData = unknown> extends ServerError<TData> {
}
export class NotImplementedError<TData = unknown> extends ServerError<TData> {
readonly code = "NOT_IMPLEMENTED";
/**
* Instantiate a new NotImplementedError.
*
@@ -319,6 +371,8 @@ export class NotImplementedError<TData = unknown> extends ServerError<TData> {
}
export class ServiceUnavailableError<TData = unknown> extends ServerError<TData> {
readonly code = "SERVICE_UNAVAILABLE";
/**
* Instantiate a new ServiceUnavailableError.
*
@@ -338,15 +392,21 @@ export class ServiceUnavailableError<TData = unknown> extends ServerError<TData>
}
}
export type ServerErrorClass<TData = unknown> = typeof ServerError<TData>;
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
export type ServerErrorJSON = {
type: "relay";
code: ServerErrorType["code"];
status: number;
message: string;
data?: any;
};
export type ServerErrorClass<TData = unknown> = typeof ServerError<TData>;
export type ServerErrorType =
| BadRequestError
| UnauthorizedError
@@ -360,4 +420,18 @@ export type ServerErrorType =
| UnprocessableContentError
| NotImplementedError
| ServiceUnavailableError
| ValidationError
| InternalServerError;
export type ErrorSource = "body" | "query" | "params" | "client";
type ValidationErrorData = {
details: ValidationErrorDetail[];
};
type ValidationErrorDetail = {
source: ErrorSource;
code: string;
field: string;
message: string;
};

View File

@@ -1,7 +1,8 @@
import z, { ZodType } from "zod";
import { ServerContext } from "./context.ts";
import { ServerError, ServerErrorClass } from "./errors.ts";
import { RouteAccess, ServerContext } from "./route.ts";
import { RouteAccess } from "./route.ts";
export class Procedure<const TState extends State = State> {
readonly type = "procedure" as const;

View File

@@ -1,6 +1,7 @@
import { match, type MatchFunction } from "path-to-regexp";
import z, { ZodObject, ZodRawShape, ZodType } from "zod";
import { ServerContext } from "./context.ts";
import { ServerError, ServerErrorClass } from "./errors.ts";
import { Hooks } from "./hooks.ts";
@@ -84,6 +85,23 @@ export class Route<const TState extends RouteState = RouteState> {
return new Route({ ...this.state, meta });
}
/**
* Set cryptographic keys used to resolve cryptographic requests.
*
* @param crypto - Crypto configuration object.
*
* @examples
*
* ```ts
* route.post("/foo").crypto({ publicKey: "..." });
* ```
*/
crypto<TCrypto extends { publicKey: string }>(
crypto: TCrypto,
): Route<Prettify<Omit<TState, "crypto"> & { crypto: TCrypto }>> {
return new Route({ ...this.state, crypto });
}
/**
* Access level of the route which acts as the first barrier of entry
* to ensure that requests are valid.
@@ -431,6 +449,9 @@ export type Routes = {
type RouteState = {
method: RouteMethod;
path: string;
crypto?: {
publicKey: string;
};
meta?: RouteMeta;
access?: RouteAccess;
params?: ZodObject;
@@ -451,10 +472,7 @@ export type RouteMeta = {
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
export type RouteAccess = "public" | "authenticated";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ServerContext {}
export type RouteAccess = "public" | "session" | ["internal:public", string] | ["internal:session", string];
type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
...args: TArgs