feat: checkpoint
This commit is contained in:
10
platform/cerbos/package.json
Normal file
10
platform/cerbos/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@platform/cerbos",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@cerbos/core": "0.25.1",
|
||||
"@cerbos/http": "0.23.3"
|
||||
}
|
||||
}
|
||||
10
platform/config/dotenv.ts
Normal file
10
platform/config/dotenv.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { load } from "@std/dotenv";
|
||||
|
||||
const env = await load();
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
export function getDotEnvVariable(key: string): string {
|
||||
return env[key] ?? Deno.env.get(key);
|
||||
}
|
||||
51
platform/config/environment.ts
Normal file
51
platform/config/environment.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { load } from "@std/dotenv";
|
||||
import type { ZodType, z } from "zod";
|
||||
|
||||
import { InvalidEnvironmentKeyError } from "./errors.ts";
|
||||
import { getServiceEnvironment, type ServiceEnvironment } from "./service.ts";
|
||||
|
||||
const env = await load();
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
export function getEnvironmentVariable<TType extends ZodType>({
|
||||
key,
|
||||
type,
|
||||
envFallback,
|
||||
fallback,
|
||||
}: {
|
||||
key: string;
|
||||
type: TType;
|
||||
envFallback?: EnvironmentFallback;
|
||||
fallback?: string;
|
||||
}): z.infer<TType> {
|
||||
const serviceEnv = getServiceEnvironment();
|
||||
const providedValue = env[key] ?? Deno.env.get(key);
|
||||
const fallbackValue = typeof envFallback === "object" ? (envFallback[serviceEnv] ?? fallback) : fallback;
|
||||
const toBeUsed = providedValue ?? fallbackValue;
|
||||
try {
|
||||
if (typeof toBeUsed === "string" && (toBeUsed.trim().startsWith("{") || toBeUsed.trim().startsWith("["))) {
|
||||
return type.parse(JSON.parse(toBeUsed));
|
||||
}
|
||||
return type.parse(toBeUsed);
|
||||
} catch (error) {
|
||||
throw new InvalidEnvironmentKeyError(key, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type EnvironmentFallback = Partial<Record<ServiceEnvironment, string>> & {
|
||||
testing?: string;
|
||||
local?: string;
|
||||
stg?: string;
|
||||
demo?: string;
|
||||
prod?: string;
|
||||
};
|
||||
22
platform/config/errors.ts
Normal file
22
platform/config/errors.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SERVICE_ENV } from "./service.ts";
|
||||
|
||||
export class InvalidServiceEnvironmentError extends Error {
|
||||
readonly code = "INVALID_SERVICE_ENVIRONMENT";
|
||||
|
||||
constructor(value: string) {
|
||||
super(
|
||||
`@platform/config requested invalid service environment, expected '${SERVICE_ENV.join(", ")}' got '${value}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidEnvironmentKeyError extends Error {
|
||||
readonly code = "INVALID_ENVIRONMENT_KEY";
|
||||
|
||||
constructor(
|
||||
key: string,
|
||||
readonly details: unknown,
|
||||
) {
|
||||
super(`@platform/config invalid environment key '${key}' provided.`);
|
||||
}
|
||||
}
|
||||
4
platform/config/mod.ts
Normal file
4
platform/config/mod.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./dotenv.ts";
|
||||
export * from "./environment.ts";
|
||||
export * from "./errors.ts";
|
||||
export * from "./service.ts";
|
||||
14
platform/config/package.json
Normal file
14
platform/config/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@platform/config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./mod.ts",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@std/dotenv": "npm:@jsr/std__dotenv@0.225.5",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
}
|
||||
19
platform/config/service.ts
Normal file
19
platform/config/service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getDotEnvVariable } from "./dotenv.ts";
|
||||
|
||||
export const SERVICE_ENV = ["testing", "local", "stg", "demo", "prod"] as const;
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
export function getServiceEnvironment(): ServiceEnvironment {
|
||||
const value = getDotEnvVariable("SERVICE_ENV");
|
||||
if (value === undefined) {
|
||||
return "local";
|
||||
}
|
||||
if ((SERVICE_ENV as unknown as string[]).includes(value) === false) {
|
||||
throw new Error(`Config Exception: Invalid env ${value} provided`);
|
||||
}
|
||||
return value as ServiceEnvironment;
|
||||
}
|
||||
|
||||
export type ServiceEnvironment = (typeof SERVICE_ENV)[number];
|
||||
121
platform/database/client.ts
Normal file
121
platform/database/client.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import postgres, { type Options, type Sql, type TransactionSql } from "postgres";
|
||||
import type { ZodType } from "zod";
|
||||
|
||||
import { takeAll, takeOne } from "./parser.ts";
|
||||
|
||||
const storage = new AsyncLocalStorage<TransactionSql>();
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Database
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export class Client {
|
||||
/**
|
||||
* Cached SQL instance.
|
||||
*/
|
||||
#db?: Sql;
|
||||
|
||||
/**
|
||||
* Instantiate a new Database accessor wrapper.
|
||||
*
|
||||
* @param db - Dependency container token to retrieve.
|
||||
*/
|
||||
constructor(readonly config: Options<{}>) {}
|
||||
|
||||
/**
|
||||
* SQL instance to perform queries against.
|
||||
*/
|
||||
get sql(): Sql {
|
||||
const tx = storage.getStore();
|
||||
if (tx !== undefined) {
|
||||
return tx;
|
||||
}
|
||||
return this.#getResolvedInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL instance which ignores any potential transaction established
|
||||
* in instance scope.
|
||||
*/
|
||||
get direct(): Sql {
|
||||
return this.#getResolvedInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves cached SQL instance or attempts to create and return
|
||||
* a new instance.
|
||||
*/
|
||||
#getResolvedInstance(): Sql {
|
||||
if (this.#db === undefined) {
|
||||
this.#db = postgres(this.config);
|
||||
}
|
||||
return this.#db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a SQL transaction by wrapping a new db instance with a
|
||||
* new transaction instance.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { db } from "@optio/database/client.ts";
|
||||
*
|
||||
* db.begin(async (tx) => {
|
||||
* tx`SELECT ...`
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
begin<TResponse>(cb: (tx: TransactionSql) => TResponse | Promise<TResponse>): Promise<UnwrapPromiseArray<TResponse>> {
|
||||
return this.direct.begin((tx) => storage.run(tx, () => cb(tx)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes SQL connection if it has been instantiated.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.#db !== undefined) {
|
||||
await this.#db.end();
|
||||
this.#db = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a schema pepared querying object allowing for a one or many
|
||||
* response based on the query used.
|
||||
*
|
||||
* @param schema - Zod schema to parse.
|
||||
*/
|
||||
schema<TSchema extends ZodType>(schema: TSchema) {
|
||||
return {
|
||||
/**
|
||||
* Executes a sql query and parses the result with the given schema.
|
||||
*
|
||||
* @param sql - Template string SQL value.
|
||||
*/
|
||||
one: (strings: TemplateStringsArray, ...values: any[]) => this.sql(strings, ...values).then(takeOne(schema)),
|
||||
|
||||
/**
|
||||
* Executes a sql query and parses the resulting list with the given schema.
|
||||
*
|
||||
* @param sql - Template string SQL value.
|
||||
*/
|
||||
many: (strings: TemplateStringsArray, ...values: any[]) => this.sql(strings, ...values).then(takeAll(schema)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type UnwrapPromiseArray<T> = T extends any[]
|
||||
? {
|
||||
[k in keyof T]: T[k] extends Promise<infer R> ? R : T[k];
|
||||
}
|
||||
: T;
|
||||
27
platform/database/config.ts
Normal file
27
platform/database/config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getEnvironmentVariable } from "@platform/config/environment.ts";
|
||||
import z from "zod";
|
||||
|
||||
export const config = {
|
||||
xtdb: {
|
||||
host: getEnvironmentVariable({
|
||||
key: "DB_XTDB_HOST",
|
||||
type: z.string(),
|
||||
fallback: "localhost",
|
||||
}),
|
||||
port: getEnvironmentVariable({
|
||||
key: "DB_XTDB_PORT",
|
||||
type: z.coerce.number(),
|
||||
fallback: "5432",
|
||||
}),
|
||||
user: getEnvironmentVariable({
|
||||
key: "DB_XTDB_USER",
|
||||
type: z.string(),
|
||||
fallback: "xtdb",
|
||||
}),
|
||||
pass: getEnvironmentVariable({
|
||||
key: "DB_XTDB_PASSWORD",
|
||||
type: z.string(),
|
||||
fallback: "xtdb",
|
||||
}),
|
||||
},
|
||||
};
|
||||
11
platform/database/package.json
Normal file
11
platform/database/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@platform/database",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@platform/config": "workspace:*",
|
||||
"postgres": "3.4.7",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
}
|
||||
29
platform/database/parser.ts
Normal file
29
platform/database/parser.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type z from "zod";
|
||||
import type { ZodType } from "zod";
|
||||
|
||||
/**
|
||||
* Takes a single record from a list of database rows.
|
||||
*
|
||||
* @param rows - List of rows to retrieve record from.
|
||||
*/
|
||||
export function takeOne<TSchema extends ZodType>(
|
||||
schema: TSchema,
|
||||
): (records: unknown[]) => z.output<TSchema> | undefined {
|
||||
return (records: unknown[]) => {
|
||||
if (records[0] === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return schema.parse(records[0]);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes all records from a list of database rows and validates each one.
|
||||
*
|
||||
* @param schema - Zod schema to validate each record against.
|
||||
*/
|
||||
export function takeAll<TSchema extends ZodType>(schema: TSchema): (records: unknown[]) => z.output<TSchema>[] {
|
||||
return (records: unknown[]) => {
|
||||
return records.map((record) => schema.parse(record));
|
||||
};
|
||||
}
|
||||
42
platform/logger/chalk.ts
Normal file
42
platform/logger/chalk.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { HexValue } from "./color/hex.ts";
|
||||
import { type BGColor, type Color, hexToBgColor, hexToColor, type Modifier, styles } from "./color/styles.ts";
|
||||
|
||||
export const chalk = {
|
||||
color(hex: HexValue): (value: string) => string {
|
||||
const color = hexToColor(hex);
|
||||
return (value: string) => `${color}${value}${styles.modifier.reset}`;
|
||||
},
|
||||
bgColor(hex: HexValue): (value: string) => string {
|
||||
const color = hexToBgColor(hex);
|
||||
return (value: string) => `${color}${value}${styles.modifier.reset}`;
|
||||
},
|
||||
} as Chalk;
|
||||
|
||||
for (const key in styles.modifier) {
|
||||
chalk[key as Modifier] = (value: string) => toModifiedValue(key as Modifier, value);
|
||||
}
|
||||
|
||||
for (const key in styles.color) {
|
||||
chalk[key as Color] = (value: string) => toColorValue(key as Color, value);
|
||||
}
|
||||
|
||||
for (const key in styles.bgColor) {
|
||||
chalk[key as BGColor] = (value: string) => toBGColorValue(key as BGColor, value);
|
||||
}
|
||||
|
||||
function toModifiedValue(key: Modifier, value: string): string {
|
||||
return `${styles.modifier[key]}${value}${styles.modifier.reset}`;
|
||||
}
|
||||
|
||||
function toColorValue(key: Color, value: string): string {
|
||||
return `${styles.color[key]}${value}${styles.modifier.reset}`;
|
||||
}
|
||||
|
||||
function toBGColorValue(key: BGColor, value: string): string {
|
||||
return `${styles.bgColor[key]}${value}${styles.modifier.reset}`;
|
||||
}
|
||||
|
||||
type Chalk = Record<Modifier | Color | BGColor, (value: string) => string> & {
|
||||
color(hex: HexValue): (value: string) => string;
|
||||
bgColor(hex: HexValue): (value: string) => string;
|
||||
};
|
||||
28
platform/logger/color/hex.ts
Normal file
28
platform/logger/color/hex.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { rgbToAnsi256 } from "./rgb.ts";
|
||||
|
||||
/**
|
||||
* Convert provided hex value to closest 256-Color value.
|
||||
*
|
||||
* @param hex - Hex to convert.
|
||||
*/
|
||||
export function hexToAnsi256(hex: HexValue) {
|
||||
const { r, g, b } = hexToRGB(hex);
|
||||
return rgbToAnsi256(r, g, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a hex value and return its RGB values.
|
||||
*
|
||||
* @param hex - Hex to convert to RGB
|
||||
* @returns
|
||||
*/
|
||||
export function hexToRGB(hex: HexValue): { r: number; g: number; b: number } {
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export type HexValue =
|
||||
`#${string | number}${string | number}${string | number}${string | number}${string | number}${string | number}`;
|
||||
24
platform/logger/color/rgb.ts
Normal file
24
platform/logger/color/rgb.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Convert RGB to the nearest 256-color ANSI value
|
||||
*
|
||||
* @param r - Red value.
|
||||
* @param g - Green value.
|
||||
* @param b - Blue value.
|
||||
*/
|
||||
export function rgbToAnsi256(r: number, g: number, b: number): number {
|
||||
if (r === g && g === b) {
|
||||
if (r < 8) return 16;
|
||||
if (r > 248) return 231;
|
||||
return Math.round(((r - 8) / 247) * 24) + 232;
|
||||
}
|
||||
|
||||
// Map RGB to 6×6×6 color cube (16–231)
|
||||
const conv = (val: number) => Math.round(val / 51);
|
||||
const ri = conv(r);
|
||||
const gi = conv(g);
|
||||
const bi = conv(b);
|
||||
|
||||
return 16 + 36 * ri + 6 * gi + bi;
|
||||
}
|
||||
|
||||
export type RGB = { r: number; g: number; b: number };
|
||||
76
platform/logger/color/styles.ts
Normal file
76
platform/logger/color/styles.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { type HexValue, hexToAnsi256 } from "./hex.ts";
|
||||
import { toEscapeSequence } from "./utilities.ts";
|
||||
|
||||
export const styles = {
|
||||
modifier: {
|
||||
reset: toEscapeSequence(0), // Reset to normal
|
||||
bold: toEscapeSequence(1), // Bold text
|
||||
dim: toEscapeSequence(2), // Dim text
|
||||
italic: toEscapeSequence(3), // Italic text
|
||||
underline: toEscapeSequence(4), // Underlined text
|
||||
overline: toEscapeSequence(53), // Overline text
|
||||
inverse: toEscapeSequence(7), // Inverse
|
||||
hidden: toEscapeSequence(8), // Hidden text
|
||||
strikethrough: toEscapeSequence(9), // Strikethrough
|
||||
},
|
||||
|
||||
color: {
|
||||
black: toEscapeSequence(30), // Black color
|
||||
red: toEscapeSequence(31), // Red color
|
||||
green: toEscapeSequence(32), // Green color
|
||||
yellow: toEscapeSequence(33), // Yellow color
|
||||
blue: toEscapeSequence(34), // Blue color
|
||||
magenta: toEscapeSequence(35), // Magenta color
|
||||
cyan: toEscapeSequence(36), // Cyan color
|
||||
white: toEscapeSequence(37), // White color
|
||||
orange: hexToColor("#FFA500"),
|
||||
|
||||
// Bright colors
|
||||
blackBright: toEscapeSequence(90),
|
||||
gray: toEscapeSequence(90), // Alias for blackBright
|
||||
grey: toEscapeSequence(90), // Alias for blackBright
|
||||
redBright: toEscapeSequence(91),
|
||||
greenBright: toEscapeSequence(92),
|
||||
yellowBright: toEscapeSequence(93),
|
||||
blueBright: toEscapeSequence(94),
|
||||
magentaBright: toEscapeSequence(95),
|
||||
cyanBright: toEscapeSequence(96),
|
||||
whiteBright: toEscapeSequence(97),
|
||||
},
|
||||
|
||||
bgColor: {
|
||||
bgBlack: toEscapeSequence(40),
|
||||
bgRed: toEscapeSequence(41),
|
||||
bgGreen: toEscapeSequence(42),
|
||||
bgYellow: toEscapeSequence(43),
|
||||
bgBlue: toEscapeSequence(44),
|
||||
bgMagenta: toEscapeSequence(45),
|
||||
bgCyan: toEscapeSequence(46),
|
||||
bgWhite: toEscapeSequence(47),
|
||||
bgOrange: hexToBgColor("#FFA500"),
|
||||
|
||||
// Bright background colors
|
||||
bgBlackBright: toEscapeSequence(100),
|
||||
bgGray: toEscapeSequence(100), // Alias for bgBlackBright
|
||||
bgGrey: toEscapeSequence(100), // Alias for bgBlackBright
|
||||
bgRedBright: toEscapeSequence(101),
|
||||
bgGreenBright: toEscapeSequence(102),
|
||||
bgYellowBright: toEscapeSequence(103),
|
||||
bgBlueBright: toEscapeSequence(104),
|
||||
bgMagentaBright: toEscapeSequence(105),
|
||||
bgCyanBright: toEscapeSequence(106),
|
||||
bgWhiteBright: toEscapeSequence(107),
|
||||
},
|
||||
};
|
||||
|
||||
export function hexToColor(hex: HexValue): string {
|
||||
return toEscapeSequence(`38;5;${hexToAnsi256(hex)}`); // Foreground color
|
||||
}
|
||||
|
||||
export function hexToBgColor(hex: HexValue): string {
|
||||
return toEscapeSequence(`48;5;${hexToAnsi256(hex)}`); // Background color
|
||||
}
|
||||
|
||||
export type Modifier = keyof typeof styles.modifier;
|
||||
export type Color = keyof typeof styles.color;
|
||||
export type BGColor = keyof typeof styles.bgColor;
|
||||
3
platform/logger/color/utilities.ts
Normal file
3
platform/logger/color/utilities.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function toEscapeSequence(value: string | number): `\x1b[${string}m` {
|
||||
return `\x1b[${value}m`;
|
||||
}
|
||||
13
platform/logger/config.ts
Normal file
13
platform/logger/config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getEnvironmentVariable } from "@platform/config/environment.ts";
|
||||
import z from "zod";
|
||||
|
||||
export const config = {
|
||||
level: getEnvironmentVariable({
|
||||
key: "LOG_LEVEL",
|
||||
type: z.string(),
|
||||
fallback: "info",
|
||||
envFallback: {
|
||||
local: "debug",
|
||||
},
|
||||
}),
|
||||
};
|
||||
19
platform/logger/format/event-store.ts
Normal file
19
platform/logger/format/event-store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { EventValidationError } from "@valkyr/event-store";
|
||||
|
||||
import type { Level } from "../level.ts";
|
||||
import { getTracedAt } from "../stack.ts";
|
||||
|
||||
export function toEventStoreLog(arg: any, level: Level): any {
|
||||
if (arg instanceof EventValidationError) {
|
||||
const obj: any = {
|
||||
origin: "EventStore",
|
||||
message: arg.message,
|
||||
at: getTracedAt(arg.stack, "/api/domains"),
|
||||
data: arg.errors,
|
||||
};
|
||||
if (level === "debug") {
|
||||
obj.stack = arg.stack;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
18
platform/logger/format/server.ts
Normal file
18
platform/logger/format/server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ServerError } from "@platform/relay";
|
||||
|
||||
import type { Level } from "../level.ts";
|
||||
import { getTracedAt } from "../stack.ts";
|
||||
|
||||
export function toServerLog(arg: any, level: Level): any {
|
||||
if (arg instanceof ServerError) {
|
||||
const obj: any = {
|
||||
message: arg.message,
|
||||
data: arg.data,
|
||||
at: getTracedAt(arg.stack, "/api/domains"),
|
||||
};
|
||||
if (level === "debug") {
|
||||
obj.stack = arg.stack;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
8
platform/logger/level.ts
Normal file
8
platform/logger/level.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const logLevel = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warning: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
export type Level = "debug" | "error" | "warning" | "info";
|
||||
95
platform/logger/logger.ts
Normal file
95
platform/logger/logger.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { chalk } from "./chalk.ts";
|
||||
import { type Level, logLevel } from "./level.ts";
|
||||
|
||||
export class Logger {
|
||||
#level: Level = "info";
|
||||
#config: Config;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.#config = config;
|
||||
}
|
||||
|
||||
get #prefix(): [string?] {
|
||||
if (this.#config.prefix !== undefined) {
|
||||
return [chalk.bold(chalk.green(this.#config.prefix))];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the highest logging level in the order of debug, info, warn, error.
|
||||
*
|
||||
* When value is 'info', info, warn and error will be logged and debug
|
||||
* will be ignored.
|
||||
*
|
||||
* @param value Highest log level.
|
||||
*/
|
||||
level(value: Level): this {
|
||||
this.#level = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new logger instance with the given name as prefix.
|
||||
*
|
||||
* @param name - Prefix name.
|
||||
*/
|
||||
prefix(name: string): Logger {
|
||||
return new Logger({ prefix: name, loggers: this.#config.loggers }).level(this.#level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a debug message to terminal.
|
||||
*/
|
||||
debug(...args: any[]) {
|
||||
if (this.#isLevelEnabled(0)) {
|
||||
console.log(new Date(), chalk.bold("Debug"), ...this.#prefix, ...args.map(this.#toFormattedArg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a info message to terminal.
|
||||
*/
|
||||
info(...args: any[]) {
|
||||
if (this.#isLevelEnabled(1)) {
|
||||
console.log(new Date(), chalk.bold(chalk.blue("Info")), ...this.#prefix, ...args.map(this.#toFormattedArg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a warning message to terminal.
|
||||
*/
|
||||
warn(...args: any[]) {
|
||||
if (this.#isLevelEnabled(2)) {
|
||||
console.log(new Date(), chalk.bold(chalk.orange("Warning")), ...this.#prefix, ...args.map(this.#toFormattedArg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a errpr message to terminal.
|
||||
*/
|
||||
error(...args: any[]) {
|
||||
if (this.#isLevelEnabled(3)) {
|
||||
console.log(new Date(), chalk.bold(chalk.red("Error")), ...this.#prefix, ...args.map(this.#toFormattedArg));
|
||||
}
|
||||
}
|
||||
|
||||
#isLevelEnabled(level: 0 | 1 | 2 | 3): boolean {
|
||||
return level >= logLevel[this.#level];
|
||||
}
|
||||
|
||||
#toFormattedArg = (arg: any): string => {
|
||||
for (const logger of this.#config.loggers) {
|
||||
const res = logger(arg, this.#level);
|
||||
if (res !== undefined) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
return arg;
|
||||
};
|
||||
}
|
||||
|
||||
type Config = {
|
||||
prefix?: string;
|
||||
loggers: ((arg: any, level: Level) => any)[];
|
||||
};
|
||||
7
platform/logger/mod.ts
Normal file
7
platform/logger/mod.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { toEventStoreLog } from "./format/event-store.ts";
|
||||
import { toServerLog } from "./format/server.ts";
|
||||
import { Logger } from "./logger.ts";
|
||||
|
||||
export const logger = new Logger({
|
||||
loggers: [toServerLog, toEventStoreLog],
|
||||
});
|
||||
15
platform/logger/package.json
Normal file
15
platform/logger/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@platform/logger",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./mod.ts",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@platform/config": "workspace:*",
|
||||
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
}
|
||||
20
platform/logger/stack.ts
Normal file
20
platform/logger/stack.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Fetch the most closest relevant error from the local code base so it can
|
||||
* be more easily traced to its source.
|
||||
*
|
||||
* @param stack - Error stack.
|
||||
* @param search - Relevant stack line search value.
|
||||
*/
|
||||
export function getTracedAt(stack: string | undefined, search: string): string | undefined {
|
||||
if (stack === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const firstMatch = stack.split("\n").find((line) => line.includes(search));
|
||||
if (firstMatch === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return firstMatch
|
||||
.replace(/^.*?(file:\/\/\/)/, "$1")
|
||||
.replace(/\)$/, "")
|
||||
.trim();
|
||||
}
|
||||
302
platform/relay/adapters/http.ts
Normal file
302
platform/relay/adapters/http.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
assertServerErrorResponse,
|
||||
type RelayAdapter,
|
||||
type RelayInput,
|
||||
type RelayResponse,
|
||||
type ServerErrorResponse,
|
||||
} from "../libraries/adapter.ts";
|
||||
import { ServerError, type ServerErrorType } from "../libraries/errors.ts";
|
||||
|
||||
/**
|
||||
* HttpAdapter provides a unified transport layer for Relay.
|
||||
*
|
||||
* It supports sending JSON objects, nested structures, arrays, and file uploads
|
||||
* via FormData. The adapter automatically detects the payload type and formats
|
||||
* the request accordingly. Responses are normalized into `RelayResponse`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const adapter = new HttpAdapter({ url: "https://api.example.com" });
|
||||
*
|
||||
* // Sending JSON data
|
||||
* const jsonResponse = await adapter.send({
|
||||
* method: "POST",
|
||||
* endpoint: "/users",
|
||||
* body: { name: "Alice", age: 30 },
|
||||
* });
|
||||
*
|
||||
* // Sending files and nested objects
|
||||
* const formResponse = await adapter.send({
|
||||
* method: "POST",
|
||||
* endpoint: "/upload",
|
||||
* body: {
|
||||
* user: { name: "Bob", avatar: fileInput.files[0] },
|
||||
* documents: [fileInput.files[1], fileInput.files[2]],
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class HttpAdapter implements RelayAdapter {
|
||||
/**
|
||||
* Instantiate a new HttpAdapter instance.
|
||||
*
|
||||
* @param options - Adapter options.
|
||||
*/
|
||||
constructor(readonly options: HttpAdapterOptions) {}
|
||||
|
||||
/**
|
||||
* Override the initial url value set by instantiator.
|
||||
*/
|
||||
set url(value: string) {
|
||||
this.options.url = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the URL value from options object.
|
||||
*/
|
||||
get url() {
|
||||
return this.options.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full URL from given endpoint.
|
||||
*
|
||||
* @param endpoint - Endpoint to get url for.
|
||||
*/
|
||||
getUrl(endpoint: string): string {
|
||||
return `${this.url}${endpoint}`;
|
||||
}
|
||||
|
||||
async send({ method, endpoint, query, body, headers = new Headers() }: RelayInput): Promise<RelayResponse> {
|
||||
const init: RequestInit = { method, headers };
|
||||
|
||||
// ### Before Request
|
||||
// If any before request hooks has been defined, we run them here passing in the
|
||||
// request headers for further modification.
|
||||
|
||||
await this.#beforeRequest(headers);
|
||||
|
||||
// ### Body
|
||||
|
||||
if (body !== undefined) {
|
||||
const type = this.#getRequestFormat(body);
|
||||
if (type === "form-data") {
|
||||
headers.delete("content-type");
|
||||
init.body = this.#getFormData(body);
|
||||
}
|
||||
if (type === "json") {
|
||||
headers.set("content-type", "application/json");
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
// ### Response
|
||||
|
||||
return this.request(`${endpoint}${query}`, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a fetch request using the given fetch options and returns
|
||||
* a relay formatted response.
|
||||
*
|
||||
* @param endpoint - Which endpoint to submit request to.
|
||||
* @param init - Request init details to submit with the request.
|
||||
*/
|
||||
async request(endpoint: string, init?: RequestInit): Promise<RelayResponse> {
|
||||
return this.#toResponse(await fetch(this.getUrl(endpoint), init));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run before request operations.
|
||||
*
|
||||
* @param headers - Headers to pass to hooks.
|
||||
*/
|
||||
async #beforeRequest(headers: Headers) {
|
||||
if (this.options.hooks?.beforeRequest !== undefined) {
|
||||
for (const hook of this.options.hooks.beforeRequest) {
|
||||
await hook(headers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the parser method required for the request.
|
||||
*
|
||||
* @param body - Request body.
|
||||
*/
|
||||
#getRequestFormat(body: unknown): "form-data" | "json" {
|
||||
if (body instanceof FormData) {
|
||||
return "form-data";
|
||||
}
|
||||
if (containsFile(body) === true) {
|
||||
return "form-data";
|
||||
}
|
||||
return "json";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get FormData instance for the given body.
|
||||
*
|
||||
* @param body - Request body.
|
||||
*/
|
||||
#getFormData(data: Record<string, unknown>, formData = new FormData(), parentKey?: string): FormData {
|
||||
for (const key in data) {
|
||||
const value = data[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
const formKey = parentKey ? `${parentKey}[${key}]` : key;
|
||||
|
||||
if (value instanceof File) {
|
||||
formData.append(formKey, value, value.name);
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
if (item instanceof File) {
|
||||
formData.append(`${formKey}[${index}]`, item, item.name);
|
||||
} else if (typeof item === "object") {
|
||||
this.#getFormData(item as Record<string, unknown>, formData, `${formKey}[${index}]`);
|
||||
} else {
|
||||
formData.append(`${formKey}[${index}]`, String(item));
|
||||
}
|
||||
});
|
||||
} else if (typeof value === "object") {
|
||||
this.#getFormData(value as Record<string, unknown>, formData, formKey);
|
||||
} else {
|
||||
formData.append(formKey, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a fetch response to a compliant relay response.
|
||||
*
|
||||
* @param response - Fetch response to convert.
|
||||
*/
|
||||
async #toResponse(response: Response): Promise<RelayResponse> {
|
||||
const type = response.headers.get("content-type");
|
||||
|
||||
// ### Content Type
|
||||
// Ensure that the server responds with a 'content-type' definition. We should
|
||||
// always expect the server to respond with a type.
|
||||
|
||||
if (type === null) {
|
||||
return {
|
||||
result: "error",
|
||||
headers: response.headers,
|
||||
error: {
|
||||
code: "CONTENT_TYPE_MISSING",
|
||||
status: response.status,
|
||||
message: "Missing 'content-type' in header returned from server.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ### Empty Response
|
||||
// If the response comes back with empty response status 204 we simply return a
|
||||
// empty success.
|
||||
|
||||
if (response.status === 204) {
|
||||
return {
|
||||
result: "success",
|
||||
headers: response.headers,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ### JSON
|
||||
// If the 'content-type' contains 'json' we treat it as a 'json' compliant response
|
||||
// and attempt to resolve it as such.
|
||||
|
||||
if (type.includes("json") === true) {
|
||||
const parsed = await response.json();
|
||||
if ("data" in parsed) {
|
||||
return {
|
||||
result: "success",
|
||||
headers: response.headers,
|
||||
data: parsed.data,
|
||||
};
|
||||
}
|
||||
if ("error" in parsed) {
|
||||
return {
|
||||
result: "error",
|
||||
headers: response.headers,
|
||||
error: this.#toError(parsed),
|
||||
};
|
||||
}
|
||||
return {
|
||||
result: "error",
|
||||
headers: response.headers,
|
||||
error: {
|
||||
code: "INVALID_SERVER_RESPONSE",
|
||||
status: response.status,
|
||||
message: "Unsupported 'json' body returned from server, missing 'data' or 'error' key.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ### Error
|
||||
// If the 'content-type' is not a JSON response from the API then we check if the
|
||||
// response status is an error code.
|
||||
|
||||
if (response.status >= 400) {
|
||||
return {
|
||||
result: "error",
|
||||
headers: response.headers,
|
||||
error: {
|
||||
code: "SERVER_ERROR_RESPONSE",
|
||||
status: response.status,
|
||||
message: await response.text(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ### Success
|
||||
// If the 'content-type' is not a JSON response from the API and the request is not
|
||||
// an error we simply return the pure response in the data key.
|
||||
|
||||
return {
|
||||
result: "success",
|
||||
headers: response.headers,
|
||||
data: response,
|
||||
};
|
||||
}
|
||||
|
||||
#toError(candidate: unknown, status: number = 500): ServerErrorType | ServerErrorResponse["error"] {
|
||||
if (assertServerErrorResponse(candidate)) {
|
||||
return ServerError.fromJSON(candidate.error);
|
||||
}
|
||||
if (typeof candidate === "string") {
|
||||
return {
|
||||
code: "ERROR",
|
||||
status,
|
||||
message: candidate,
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "UNSUPPORTED_SERVER_ERROR",
|
||||
status,
|
||||
message: "Unsupported 'error' returned from server.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function containsFile(value: unknown): boolean {
|
||||
if (value instanceof File) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(containsFile);
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.values(value).some(containsFile);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type HttpAdapterOptions = {
|
||||
url: string;
|
||||
hooks?: {
|
||||
beforeRequest?: ((headers: Headers) => Promise<void>)[];
|
||||
};
|
||||
};
|
||||
89
platform/relay/libraries/adapter.ts
Normal file
89
platform/relay/libraries/adapter.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import z from "zod";
|
||||
|
||||
import type { ServerErrorType } from "./errors.ts";
|
||||
import type { RouteMethod } from "./route.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const ServerErrorResponseSchema = z.object({
|
||||
error: z.object({
|
||||
code: z.any(),
|
||||
status: z.number(),
|
||||
message: z.string(),
|
||||
data: z.any().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if the given candidate is a valid relay error response.
|
||||
*
|
||||
* @param candidate - Candidate to check.
|
||||
*/
|
||||
export function assertServerErrorResponse(candidate: unknown): candidate is ServerErrorResponse {
|
||||
return ServerErrorResponseSchema.safeParse(candidate).success;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RelayAdapter = {
|
||||
readonly url: string;
|
||||
|
||||
/**
|
||||
* Return the full URL from given endpoint.
|
||||
*
|
||||
* @param endpoint - Endpoint to get url for.
|
||||
*/
|
||||
getUrl(endpoint: string): string;
|
||||
|
||||
/**
|
||||
* Send a request to the configured relay url.
|
||||
*
|
||||
* @param input - Request input parameters.
|
||||
* @param publicKey - Key to encrypt the payload with.
|
||||
*/
|
||||
send(input: RelayInput, publicKey?: string): Promise<RelayResponse>;
|
||||
|
||||
/**
|
||||
* Sends a fetch request using the given options and returns a
|
||||
* raw response.
|
||||
*
|
||||
* @param options - Relay request options.
|
||||
*/
|
||||
request(input: RequestInfo | URL, init?: RequestInit): Promise<RelayResponse>;
|
||||
};
|
||||
|
||||
export type RelayInput = {
|
||||
method: RouteMethod;
|
||||
endpoint: string;
|
||||
query?: string;
|
||||
body?: Record<string, unknown>;
|
||||
headers?: Headers;
|
||||
};
|
||||
|
||||
export type RelayResponse<TData = unknown, TError = ServerErrorType | ServerErrorResponse["error"]> =
|
||||
| {
|
||||
result: "success";
|
||||
headers: Headers;
|
||||
data: TData;
|
||||
}
|
||||
| {
|
||||
result: "error";
|
||||
headers: Headers;
|
||||
error: TError;
|
||||
};
|
||||
|
||||
export type ServerErrorResponse = z.infer<typeof ServerErrorResponseSchema>;
|
||||
198
platform/relay/libraries/client.ts
Normal file
198
platform/relay/libraries/client.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||
|
||||
import type { ZodObject, ZodType } from "zod";
|
||||
|
||||
import type { RelayAdapter, RelayInput, RelayResponse } from "./adapter.ts";
|
||||
import { Route, type RouteFn, type Routes } from "./route.ts";
|
||||
|
||||
/**
|
||||
* Factory method for generating a new relay client instance.
|
||||
*
|
||||
* @param config - Client configuration.
|
||||
* @param procedures - Map of routes to make available to the client.
|
||||
*/
|
||||
export function makeClient<TRoutes extends Routes>(config: Config, routes: TRoutes): RelayClient<TRoutes> {
|
||||
const client: any = {
|
||||
getUrl: config.adapter.getUrl.bind(config.adapter),
|
||||
request: config.adapter.request.bind(config.adapter),
|
||||
};
|
||||
for (const key in routes) {
|
||||
const route = routes[key];
|
||||
if (route instanceof Route) {
|
||||
client[key] = getRouteFn(route, config);
|
||||
} else if (typeof route === "function") {
|
||||
client[key] = route;
|
||||
} else {
|
||||
client[key] = getNestedRoute(config, route);
|
||||
}
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function getNestedRoute<TRoutes extends Routes>(config: Config, routes: TRoutes): RelayClient<TRoutes> {
|
||||
const nested: any = {};
|
||||
for (const key in routes) {
|
||||
const route = routes[key];
|
||||
if (route instanceof Route) {
|
||||
nested[key] = getRouteFn(route, config);
|
||||
} else if (typeof route === "function") {
|
||||
nested[key] = route;
|
||||
} else {
|
||||
nested[key] = getNestedRoute(config, route);
|
||||
}
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
|
||||
function getRouteFn(route: Route, { adapter }: Config) {
|
||||
return async (options: any = {}) => {
|
||||
const input: RelayInput = {
|
||||
method: route.state.method,
|
||||
endpoint: route.state.path,
|
||||
query: "",
|
||||
};
|
||||
|
||||
// ### Params
|
||||
// Prepare request parameters by replacing :param notations with the
|
||||
// parameter argument provided.
|
||||
|
||||
if (route.state.params !== undefined) {
|
||||
const params = await toParsedArgs(
|
||||
route.state.params,
|
||||
options.params,
|
||||
`Invalid 'params' passed to ${route.state.path} handler.`,
|
||||
);
|
||||
for (const key in params) {
|
||||
input.endpoint = input.endpoint.replace(`:${key}`, encodeURIComponent(params[key]));
|
||||
}
|
||||
}
|
||||
|
||||
// ### Query
|
||||
// Prepare request query by looping through the query argument and
|
||||
// creating a query string to pass onto the fetch request.
|
||||
|
||||
if (route.state.query !== undefined) {
|
||||
const query = await toParsedArgs(
|
||||
route.state.query,
|
||||
options.query,
|
||||
`Invalid 'query' passed to ${route.state.path} handler.`,
|
||||
);
|
||||
const pieces: string[] = [];
|
||||
for (const key in query) {
|
||||
pieces.push(`${encodeURIComponent(key)}=${encodeURIComponent(query[key])}`);
|
||||
}
|
||||
if (pieces.length > 0) {
|
||||
input.query = `?${pieces.join("&")}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ### Body
|
||||
// Attach the body to the input which is handled internally based on the
|
||||
// type of fetch body is submitted.
|
||||
|
||||
if (route.state.body !== undefined) {
|
||||
input.body = await toParsedArgs(
|
||||
route.state.body,
|
||||
options.body,
|
||||
`Invalid 'body' passed to ${route.state.path} handler.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ### Request Init
|
||||
// List of request init options that we can extract and forward to the
|
||||
// request adapter.
|
||||
|
||||
if (options.headers !== undefined) {
|
||||
input.headers = new Headers(options.headers);
|
||||
}
|
||||
|
||||
// ### Fetch
|
||||
|
||||
const response = await adapter.send(input);
|
||||
|
||||
if ("data" in response && route.state.response !== undefined) {
|
||||
response.data = route.state.response.parse(response.data);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
async function toParsedArgs(
|
||||
zod: ZodType,
|
||||
args: unknown,
|
||||
msg: string,
|
||||
): Promise<Record<string, string | number | boolean>> {
|
||||
const result = await zod.safeParseAsync(args);
|
||||
if (result.success === false) {
|
||||
throw new Error(msg);
|
||||
}
|
||||
return result.data as Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RelayClient<TRoutes extends Routes> = RelayRequest & RelayRoutes<TRoutes>;
|
||||
|
||||
type RelayRequest = {
|
||||
url: string;
|
||||
getUrl: (endpoint: string) => string;
|
||||
request: <TData = unknown>(input: RequestInfo | URL, init?: RequestInit) => Promise<RelayResponse<TData>>;
|
||||
};
|
||||
|
||||
type RelayRoutes<TRoutes extends Routes> = {
|
||||
[TKey in keyof TRoutes]: TRoutes[TKey] extends Route
|
||||
? ClientRoute<TRoutes[TKey]>
|
||||
: TRoutes[TKey] extends RouteFn
|
||||
? TRoutes[TKey]
|
||||
: TRoutes[TKey] extends Routes
|
||||
? RelayRoutes<TRoutes[TKey]>
|
||||
: never;
|
||||
};
|
||||
|
||||
type ClientRoute<TRoute extends Route> = HasPayload<TRoute> extends true
|
||||
? (
|
||||
payload: Prettify<
|
||||
(TRoute["state"]["params"] extends ZodObject ? { params: TRoute["$params"] } : {}) &
|
||||
(TRoute["state"]["query"] extends ZodObject ? { query: TRoute["$query"] } : {}) &
|
||||
(TRoute["state"]["body"] extends ZodType ? { body: TRoute["$body"] } : {}) & {
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
>,
|
||||
) => RouteResponse<TRoute>
|
||||
: (payload?: { headers: HeadersInit }) => RouteResponse<TRoute>;
|
||||
|
||||
type HasPayload<TRoute extends Route> = TRoute["state"]["params"] extends ZodObject
|
||||
? true
|
||||
: TRoute["state"]["query"] extends ZodObject
|
||||
? true
|
||||
: TRoute["state"]["body"] extends ZodType
|
||||
? true
|
||||
: false;
|
||||
|
||||
type RouteResponse<TRoute extends Route> = Promise<RelayResponse<RouteOutput<TRoute>, RouteErrors<TRoute>>> & {
|
||||
$params: TRoute["$params"];
|
||||
$query: TRoute["$query"];
|
||||
$body: TRoute["$body"];
|
||||
$response: TRoute["$response"];
|
||||
};
|
||||
|
||||
type RouteOutput<TRoute extends Route> = TRoute["state"]["response"] extends ZodType ? TRoute["$response"] : null;
|
||||
|
||||
type RouteErrors<TRoute extends Route> = InstanceType<TRoute["state"]["errors"][number]>;
|
||||
|
||||
type Config = {
|
||||
adapter: RelayAdapter;
|
||||
};
|
||||
|
||||
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
||||
5
platform/relay/libraries/context.ts
Normal file
5
platform/relay/libraries/context.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ServerContext {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const context: ServerContext = {} as any;
|
||||
437
platform/relay/libraries/errors.ts
Normal file
437
platform/relay/libraries/errors.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import type { ZodError } from "zod";
|
||||
|
||||
export abstract class ServerError<TData = unknown> extends Error {
|
||||
abstract readonly code: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly data?: TData,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a server delivered JSON error to its native instance.
|
||||
*
|
||||
* @param error - Error JSON.
|
||||
*/
|
||||
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(error.message, error.data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert error instance to a JSON object.
|
||||
*/
|
||||
toJSON(): ServerErrorJSON {
|
||||
return {
|
||||
code: this.code as ServerErrorJSON["code"],
|
||||
status: this.status,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "BAD_REQUEST";
|
||||
|
||||
/**
|
||||
* Instantiate a new BadRequestError.
|
||||
*
|
||||
* The **HTTP 400 Bad Request** response status code indicates that the server
|
||||
* 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) {
|
||||
super(message, 400, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "UNAUTHORIZED";
|
||||
|
||||
/**
|
||||
* Instantiate a new UnauthorizedError.
|
||||
*
|
||||
* The **HTTP 401 Unauthorized** response status code indicates that the client
|
||||
* request has not been completed because it lacks valid authentication
|
||||
* credentials for the requested resource.
|
||||
*
|
||||
* This status code is sent with an HTTP WWW-Authenticate response header that
|
||||
* contains information on how the client can request for the resource again after
|
||||
* prompting the user for authentication credentials.
|
||||
*
|
||||
* This status code is similar to the **403 Forbidden** status code, except that
|
||||
* in situations resulting in this status code, user authentication can allow
|
||||
* access to the resource.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Unauthorized".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Unauthorized", data?: TData) {
|
||||
super(message, 401, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "FORBIDDEN";
|
||||
|
||||
/**
|
||||
* Instantiate a new ForbiddenError.
|
||||
*
|
||||
* The **HTTP 403 Forbidden** response status code indicates that the server
|
||||
* understands the request but refuses to authorize it.
|
||||
*
|
||||
* This status is similar to **401**, but for the **403 Forbidden** status code
|
||||
* re-authenticating makes no difference. The access is permanently forbidden and
|
||||
* tied to the application logic, such as insufficient rights to a resource.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Forbidden".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Forbidden", data?: TData) {
|
||||
super(message, 403, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "NOT_FOUND";
|
||||
|
||||
/**
|
||||
* Instantiate a new NotFoundError.
|
||||
*
|
||||
* The **HTTP 404 Not Found** response status code indicates that the server
|
||||
* cannot find the requested resource. Links that lead to a 404 page are often
|
||||
* called broken or dead links and can be subject to link rot.
|
||||
*
|
||||
* A 404 status code only indicates that the resource is missing: not whether the
|
||||
* absence is temporary or permanent. If a resource is permanently removed,
|
||||
* use the **410 _(Gone)_** status instead.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Not Found".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Not Found", data?: TData) {
|
||||
super(message, 404, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class MethodNotAllowedError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "METHOD_NOT_ALLOWED";
|
||||
|
||||
/**
|
||||
* Instantiate a new MethodNotAllowedError.
|
||||
*
|
||||
* The **HTTP 405 Method Not Allowed** response code indicates that the
|
||||
* request method is known by the server but is not supported by the target resource.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Method Not Allowed".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Method Not Allowed", data?: TData) {
|
||||
super(message, 405, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotAcceptableError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "NOT_ACCEPTABLE";
|
||||
|
||||
/**
|
||||
* Instantiate a new NotAcceptableError.
|
||||
*
|
||||
* The **HTTP 406 Not Acceptable** client error response code indicates that the
|
||||
* server cannot produce a response matching the list of acceptable values
|
||||
* defined in the request, and that the server is unwilling to supply a default
|
||||
* representation.
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Not Acceptable".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Not Acceptable", data?: TData) {
|
||||
super(message, 406, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "CONFLICT";
|
||||
|
||||
/**
|
||||
* Instantiate a new ConflictError.
|
||||
*
|
||||
* The **HTTP 409 Conflict** response status code indicates a request conflict
|
||||
* with the current state of the target resource.
|
||||
*
|
||||
* Conflicts are most likely to occur in response to a PUT request. For example,
|
||||
* you may get a 409 response when uploading a file that is older than the
|
||||
* existing one on the server, resulting in a version control conflict.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Conflict".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Conflict", data?: TData) {
|
||||
super(message, 409, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class GoneError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "GONE";
|
||||
|
||||
/**
|
||||
* Instantiate a new GoneError.
|
||||
*
|
||||
* The **HTTP 410 Gone** indicates that the target resource is no longer
|
||||
* available at the origin server and that this condition is likely to be
|
||||
* permanent. A 410 response is cacheable by default.
|
||||
*
|
||||
* Clients should not repeat requests for resources that return a 410 response,
|
||||
* and website owners should remove or replace links that return this code. If
|
||||
* server owners don't know whether this condition is temporary or permanent,
|
||||
* a 404 status code should be used instead.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/410
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Gone".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Gone", data?: TData) {
|
||||
super(message, 410, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedMediaTypeError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "UNSUPPORTED_MEDIA_TYPE";
|
||||
|
||||
/**
|
||||
* Instantiate a new UnsupportedMediaTypeError.
|
||||
*
|
||||
* The **HTTP 415 Unsupported Media Type** response code indicates that the
|
||||
* server refuses to accept the request because the payload format is in an unsupported format.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Unsupported Media Type".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Unsupported Media Type", data?: TData) {
|
||||
super(message, 415, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnprocessableContentError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "UNPROCESSABLE_CONTENT";
|
||||
|
||||
/**
|
||||
* Instantiate a new UnprocessableContentError.
|
||||
*
|
||||
* The **HTTP 422 Unprocessable Content** client error response status code
|
||||
* indicates that the server understood the content type of the request entity,
|
||||
* and the syntax of the request entity was correct, but it was unable to
|
||||
* process the contained instructions.
|
||||
*
|
||||
* Clients that receive a 422 response should expect that repeating the request
|
||||
* without modification will fail with the same error.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Unprocessable Content".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Unprocessable Content", data?: TData) {
|
||||
super(message, 422, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "VALIDATION";
|
||||
|
||||
/**
|
||||
* Instantiate a new ValidationError.
|
||||
*
|
||||
* 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: "Validation Failed".
|
||||
* @param data - Data with validation failure details.
|
||||
*/
|
||||
constructor(message = "Validation Failed", data: TData) {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
} satisfies ValidationErrorData);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "INTERNAL_SERVER";
|
||||
|
||||
/**
|
||||
* Instantiate a new InternalServerError.
|
||||
*
|
||||
* The **HTTP 500 Internal Server Error** server error response code indicates that
|
||||
* the server encountered an unexpected condition that prevented it from fulfilling
|
||||
* the request.
|
||||
*
|
||||
* This error response is a generic "catch-all" response. Usually, this indicates
|
||||
* the server cannot find a better 5xx error code to response. Sometimes, server
|
||||
* administrators log error responses like the 500 status code with more details
|
||||
* about the request to prevent the error from happening again in the future.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Internal Server Error".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Internal Server Error", data?: TData) {
|
||||
super(message, 500, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotImplementedError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "NOT_IMPLEMENTED";
|
||||
|
||||
/**
|
||||
* Instantiate a new NotImplementedError.
|
||||
*
|
||||
* The **HTTP 501 Not Implemented** server error response status code means that
|
||||
* the server does not support the functionality required to fulfill the request.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/501
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Service Unavailable".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Not Implemented", data?: TData) {
|
||||
super(message, 501, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError<TData = unknown> extends ServerError<TData> {
|
||||
readonly code = "SERVICE_UNAVAILABLE";
|
||||
|
||||
/**
|
||||
* Instantiate a new ServiceUnavailableError.
|
||||
*
|
||||
* The **HTTP 503 Service Unavailable** server error response status code indicates
|
||||
* that the server is not ready to handle the request.
|
||||
*
|
||||
* This response should be used for temporary conditions and the Retry-After HTTP header
|
||||
* should contain the estimated time for the recovery of the service, if possible.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503
|
||||
*
|
||||
* @param message - Optional message to send with the error. Default: "Service Unavailable".
|
||||
* @param data - Optional data to send with the error.
|
||||
*/
|
||||
constructor(message = "Service Unavailable", data?: TData) {
|
||||
super(message, 503, data);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ServerErrorJSON = {
|
||||
code: ServerErrorType["code"];
|
||||
status: number;
|
||||
message: string;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
export type ServerErrorClass<TData = unknown> = typeof ServerError<TData>;
|
||||
|
||||
export type ServerErrorType =
|
||||
| BadRequestError
|
||||
| UnauthorizedError
|
||||
| ForbiddenError
|
||||
| NotFoundError
|
||||
| MethodNotAllowedError
|
||||
| NotAcceptableError
|
||||
| ConflictError
|
||||
| GoneError
|
||||
| UnsupportedMediaTypeError
|
||||
| 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;
|
||||
};
|
||||
242
platform/relay/libraries/procedure.ts
Normal file
242
platform/relay/libraries/procedure.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type z from "zod";
|
||||
import type { ZodType } from "zod";
|
||||
|
||||
import type { ServerContext } from "./context.ts";
|
||||
import type { ServerError, ServerErrorClass } from "./errors.ts";
|
||||
import type { RouteAccess } from "./route.ts";
|
||||
|
||||
export class Procedure<const TState extends State = State> {
|
||||
readonly type = "procedure" as const;
|
||||
|
||||
declare readonly $params: TState["params"] extends ZodType ? z.input<TState["params"]> : never;
|
||||
declare readonly $response: TState["response"] extends ZodType ? z.output<TState["response"]> : never;
|
||||
|
||||
/**
|
||||
* Instantiate a new Procedure instance.
|
||||
*
|
||||
* @param state - Procedure state.
|
||||
*/
|
||||
constructor(readonly state: TState) {}
|
||||
|
||||
/**
|
||||
* Procedure method value.
|
||||
*/
|
||||
get method(): State["method"] {
|
||||
return this.state.method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access level of the procedure which acts as the first barrier of entry
|
||||
* to ensure that requests are valid.
|
||||
*
|
||||
* By default on the server the lack of access definition will result
|
||||
* in an error as all procedures needs an access definition.
|
||||
*
|
||||
* @param access - Access level of the procedure.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* procedure
|
||||
* .method("users:create")
|
||||
* .access("public")
|
||||
* .handle(async () => {
|
||||
* // ...
|
||||
* });
|
||||
*
|
||||
* procedure
|
||||
* .method("users:get-by-id")
|
||||
* .access("session")
|
||||
* .params(z.string())
|
||||
* .handle(async (userId, context) => {
|
||||
* if (userId !== context.session.userId) {
|
||||
* return new ForbiddenError("Cannot read other users details.");
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* procedure
|
||||
* .method("users:update")
|
||||
* .access([resource("users", "update")])
|
||||
* .params(z.array(z.string(), z.object({ name: z.string() })))
|
||||
* .handle(async ([userId, payload], context) => {
|
||||
* if (userId !== context.session.userId) {
|
||||
* return new ForbiddenError("Cannot update other users details.");
|
||||
* }
|
||||
* console.log(userId, payload); // => string, { name: string }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
access<TAccess extends RouteAccess>(access: TAccess): Procedure<Omit<TState, "access"> & { access: TAccess }> {
|
||||
return new Procedure({ ...this.state, access: access as TAccess });
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the payload forwarded to the handler.
|
||||
*
|
||||
* @param params - Method payload.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* procedure
|
||||
* .method("users:create")
|
||||
* .access([resource("users", "create")])
|
||||
* .params(z.object({
|
||||
* name: z.string(),
|
||||
* email: z.email(),
|
||||
* }))
|
||||
* .handle(async ({ name, email }, context) => {
|
||||
* return { name, email, createdBy: context.session.userId };
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
params<TParams extends ZodType>(params: TParams): Procedure<Omit<TState, "params"> & { params: TParams }> {
|
||||
return new Procedure({ ...this.state, params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Instances of the possible error responses this procedure produces.
|
||||
*
|
||||
* @param errors - Error shapes of the procedure.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* procedure
|
||||
* .method("users:list")
|
||||
* .errors([
|
||||
* BadRequestError
|
||||
* ])
|
||||
* .handle(async () => {
|
||||
* return new BadRequestError();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
errors<TErrors extends ServerErrorClass[]>(errors: TErrors): Procedure<Omit<TState, "errors"> & { errors: TErrors }> {
|
||||
return new Procedure({ ...this.state, errors });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the success response this procedure produces. This is used by the transform
|
||||
* tools to ensure the client receives parsed data.
|
||||
*
|
||||
* @param response - Response shape of the procedure.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* procedure
|
||||
* .post("users:list")
|
||||
* .response(
|
||||
* z.array(
|
||||
* z.object({
|
||||
* name: z.string()
|
||||
* }),
|
||||
* )
|
||||
* )
|
||||
* .handle(async () => {
|
||||
* return [{ name: "John Doe" }];
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
response<TResponse extends ZodType>(
|
||||
response: TResponse,
|
||||
): Procedure<Omit<TState, "response"> & { response: TResponse }> {
|
||||
return new Procedure({ ...this.state, response });
|
||||
}
|
||||
|
||||
/**
|
||||
* Server handler callback method.
|
||||
*
|
||||
* Handler receives the params, query, body, actions in order of definition.
|
||||
* So if your route has params, and body the route handle method will
|
||||
* receive (params, body) as arguments.
|
||||
*
|
||||
* @param handle - Handle function to trigger when the route is executed.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* procedure
|
||||
* .method("users:list")
|
||||
* .response(
|
||||
* z.array(
|
||||
* z.object({
|
||||
* name: z.string()
|
||||
* }),
|
||||
* )
|
||||
* )
|
||||
* .handle(async () => {
|
||||
* return [{ name: "John Doe" }];
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
handle<THandleFn extends HandleFn<ServerArgs<TState>, TState["response"]>>(
|
||||
handle: THandleFn,
|
||||
): Procedure<Omit<TState, "handle"> & { handle: THandleFn }> {
|
||||
return new Procedure({ ...this.state, handle });
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Factories
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Route factories allowing for easy generation of relay compliant routes.
|
||||
*/
|
||||
export const procedure: {
|
||||
/**
|
||||
* Create a new procedure with given method name.
|
||||
*
|
||||
* @param method Name of the procedure used to match requests against.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* procedure
|
||||
* .method("users:get-by-id")
|
||||
* .params(
|
||||
* z.string().describe("Users unique identifier")
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
method<TMethod extends string>(method: TMethod): Procedure<{ method: TMethod }>;
|
||||
} = {
|
||||
method<TMethod extends string>(method: TMethod) {
|
||||
return new Procedure({ method });
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Procedures = {
|
||||
[key: string]: Procedures | Procedure;
|
||||
};
|
||||
|
||||
type State = {
|
||||
method: string;
|
||||
access?: RouteAccess;
|
||||
params?: ZodType;
|
||||
errors?: ServerErrorClass[];
|
||||
response?: ZodType;
|
||||
handle?: HandleFn;
|
||||
};
|
||||
|
||||
type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
|
||||
...args: TArgs
|
||||
) => TResponse extends ZodType
|
||||
? Promise<z.infer<TResponse> | Response | ServerError>
|
||||
: Promise<Response | ServerError | void>;
|
||||
|
||||
type ServerArgs<TState extends State> = HasInputArgs<TState> extends true
|
||||
? [z.output<TState["params"]>, ServerContext]
|
||||
: [ServerContext];
|
||||
|
||||
type HasInputArgs<TState extends State> = TState["params"] extends ZodType ? true : false;
|
||||
469
platform/relay/libraries/route.ts
Normal file
469
platform/relay/libraries/route.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { type MatchFunction, match } from "path-to-regexp";
|
||||
import z, { type ZodObject, type ZodRawShape, type ZodType } from "zod";
|
||||
|
||||
import type { ServerContext } from "./context.ts";
|
||||
import { ServerError, type ServerErrorClass } from "./errors.ts";
|
||||
|
||||
export class Route<const TState extends RouteState = RouteState> {
|
||||
readonly type = "route" as const;
|
||||
|
||||
declare readonly $params: TState["params"] extends ZodObject ? z.input<TState["params"]> : never;
|
||||
declare readonly $query: TState["query"] extends ZodObject ? z.input<TState["query"]> : never;
|
||||
declare readonly $body: TState["body"] extends ZodType ? z.input<TState["body"]> : never;
|
||||
declare readonly $response: TState["response"] extends ZodType ? z.output<TState["response"]> : never;
|
||||
|
||||
#matchFn?: MatchFunction<any>;
|
||||
|
||||
/**
|
||||
* Instantiate a new Route instance.
|
||||
*
|
||||
* @param state - Route state.
|
||||
*/
|
||||
constructor(readonly state: TState) {}
|
||||
|
||||
/**
|
||||
* HTTP Method
|
||||
*/
|
||||
get method(): RouteMethod {
|
||||
return this.state.method;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL pattern of the route.
|
||||
*/
|
||||
get matchFn(): MatchFunction<any> {
|
||||
if (this.#matchFn === undefined) {
|
||||
this.#matchFn = match(this.path);
|
||||
}
|
||||
return this.#matchFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL path
|
||||
*/
|
||||
get path(): string {
|
||||
return this.state.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provided URL matches the route pattern.
|
||||
*
|
||||
* @param url - HTTP request.url
|
||||
*/
|
||||
match(url: string): boolean {
|
||||
return this.matchFn(url) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from the provided URL based on the route pattern.
|
||||
*
|
||||
* @param url - HTTP request.url
|
||||
*/
|
||||
getParsedParams<TParams = TState["params"] extends ZodObject ? z.infer<TState["params"]> : object>(
|
||||
url: string,
|
||||
): TParams {
|
||||
const result = match(this.path)(url);
|
||||
if (result === false) {
|
||||
return {} as TParams;
|
||||
}
|
||||
return result.params as TParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the meta data for this route which can be used in e.g. OpenAPI generation
|
||||
*
|
||||
* @param meta - Meta object
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route.post("/foo").meta({ description: "Super route" });
|
||||
* ```
|
||||
*/
|
||||
meta<TRouteMeta extends RouteMeta>(meta: TRouteMeta): Route<Prettify<Omit<TState, "meta"> & { meta: TRouteMeta }>> {
|
||||
return new Route({ ...this.state, meta });
|
||||
}
|
||||
|
||||
/**
|
||||
* Access level of the route which acts as the first barrier of entry
|
||||
* to ensure that requests are valid.
|
||||
*
|
||||
* By default on the server the lack of access definition will result
|
||||
* in an error as all routes needs an access definition.
|
||||
*
|
||||
* @param access - Access level of the route.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* const hasFooBar = action
|
||||
* .make("hasFooBar")
|
||||
* .response(z.object({ foobar: z.number() }))
|
||||
* .handle(async () => {
|
||||
* return {
|
||||
* foobar: 1,
|
||||
* };
|
||||
* });
|
||||
*
|
||||
* // ### Public Endpoint
|
||||
*
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .access("public")
|
||||
* .handle(async ({ foobar }) => {
|
||||
* console.log(typeof foobar); // => number
|
||||
* });
|
||||
*
|
||||
* // ### Require Session
|
||||
*
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .access("session")
|
||||
* .handle(async ({ foobar }) => {
|
||||
* console.log(typeof foobar); // => number
|
||||
* });
|
||||
*
|
||||
* // ### Require Session & Resource Assignment
|
||||
*
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .access([resource("foo", "create")])
|
||||
* .handle(async ({ foobar }) => {
|
||||
* console.log(typeof foobar); // => number
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
access<TAccess extends RouteAccess>(access: TAccess): Route<Prettify<Omit<TState, "access"> & { access: TAccess }>> {
|
||||
return new Route({ ...this.state, access: access as TAccess });
|
||||
}
|
||||
|
||||
/**
|
||||
* Params allows for custom casting of URL parameters. If a parameter does not
|
||||
* have a corresponding zod schema the default param type is "string".
|
||||
*
|
||||
* @param params - URL params.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo/:bar")
|
||||
* .params({
|
||||
* bar: z.coerce.number()
|
||||
* })
|
||||
* .handle(async ({ bar }) => {
|
||||
* console.log(typeof bar); // => number
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
params<TParams extends ZodRawShape>(
|
||||
params: TParams,
|
||||
): Route<Prettify<Omit<TState, "params"> & { params: ZodObject<TParams> }>> {
|
||||
return new Route({ ...this.state, params: z.object(params) as any });
|
||||
}
|
||||
|
||||
/**
|
||||
* Search allows for custom casting of URL query parameters. If a parameter does
|
||||
* not have a corresponding zod schema the default param type is "string".
|
||||
*
|
||||
* @param query - URL query arguments.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .query({
|
||||
* bar: z.number({ coerce: true })
|
||||
* })
|
||||
* .handle(async ({ bar }) => {
|
||||
* console.log(typeof bar); // => number
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
query<TQuery extends ZodRawShape>(
|
||||
query: TQuery,
|
||||
): Route<Prettify<Omit<TState, "search"> & { query: ZodObject<TQuery> }>> {
|
||||
return new Route({ ...this.state, query: z.object(query) as any });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the body this route expects to receive. This is used by all
|
||||
* mutator routes and has no effect when defined on "GET" methods.
|
||||
*
|
||||
* @param body - Body the route expects.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .body(
|
||||
* z.object({
|
||||
* bar: z.number()
|
||||
* })
|
||||
* )
|
||||
* .handle(async ({ body: { bar } }) => {
|
||||
* console.log(typeof bar); // => number
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
body<TBody extends ZodType>(body: TBody): Route<Prettify<Omit<TState, "body"> & { body: TBody }>> {
|
||||
return new Route({ ...this.state, body });
|
||||
}
|
||||
|
||||
/**
|
||||
* Instances of the possible error responses this route produces.
|
||||
*
|
||||
* @param errors - Error shapes of the route.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .errors([
|
||||
* BadRequestError
|
||||
* ])
|
||||
* .handle(async () => {
|
||||
* return new BadRequestError();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
errors<TErrors extends ServerErrorClass[]>(
|
||||
errors: TErrors,
|
||||
): Route<Prettify<Omit<TState, "errors"> & { errors: TErrors }>> {
|
||||
return new Route({ ...this.state, errors });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the success response this route produces. This is used by the transform
|
||||
* tools to ensure the client receives parsed data.
|
||||
*
|
||||
* @param response - Response shape of the route.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .response(
|
||||
* z.object({
|
||||
* bar: z.number()
|
||||
* })
|
||||
* )
|
||||
* .handle(async () => {
|
||||
* return {
|
||||
* bar: 1
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
response<TResponse extends ZodType>(
|
||||
response: TResponse,
|
||||
): Route<Prettify<Omit<TState, "response"> & { response: TResponse }>> {
|
||||
return new Route({ ...this.state, response });
|
||||
}
|
||||
|
||||
/**
|
||||
* Server handler callback method.
|
||||
*
|
||||
* Handler receives the params, query, body, actions in order of definition.
|
||||
* So if your route has params, and body the route handle method will
|
||||
* receive (params, body) as arguments.
|
||||
*
|
||||
* @param handle - Handle function to trigger when the route is executed.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* relay
|
||||
* .post("/api/v1/foo/:bar")
|
||||
* .params({ bar: z.string() })
|
||||
* .body(z.tuple([z.string(), z.number()]))
|
||||
* .handle(async ({ bar }, [ "string", number ]) => {});
|
||||
* ```
|
||||
*/
|
||||
handle<THandleFn extends HandleFn<ServerArgs<TState>, TState["response"]>>(
|
||||
handle: THandleFn,
|
||||
): Route<Omit<TState, "handle"> & { handle: THandleFn }> {
|
||||
return new Route({ ...this.state, handle });
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Factories
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Route factories allowing for easy generation of relay compliant routes.
|
||||
*/
|
||||
export const route: {
|
||||
post<TPath extends string>(
|
||||
path: TPath,
|
||||
): Route<{ method: "POST"; path: TPath; content: "json"; errors: [ServerErrorClass] }>;
|
||||
get<TPath extends string>(
|
||||
path: TPath,
|
||||
): Route<{ method: "GET"; path: TPath; content: "json"; errors: [ServerErrorClass] }>;
|
||||
put<TPath extends string>(
|
||||
path: TPath,
|
||||
): Route<{ method: "PUT"; path: TPath; content: "json"; errors: [ServerErrorClass] }>;
|
||||
patch<TPath extends string>(
|
||||
path: TPath,
|
||||
): Route<{ method: "PATCH"; path: TPath; content: "json"; errors: [ServerErrorClass] }>;
|
||||
delete<TPath extends string>(
|
||||
path: TPath,
|
||||
): Route<{ method: "DELETE"; path: TPath; content: "json"; errors: [ServerErrorClass] }>;
|
||||
} = {
|
||||
/**
|
||||
* Create a new "POST" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .body(
|
||||
* z.object({ bar: z.string() })
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
post<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "POST", path, content: "json", errors: [ServerError] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new "GET" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route.get("/foo");
|
||||
* ```
|
||||
*/
|
||||
get<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "GET", path, content: "json", errors: [ServerError] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new "PUT" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .put("/foo")
|
||||
* .body(
|
||||
* z.object({ bar: z.string() })
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
put<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "PUT", path, content: "json", errors: [ServerError] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new "PATCH" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .patch("/foo")
|
||||
* .body(
|
||||
* z.object({ bar: z.string() })
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
patch<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "PATCH", path, content: "json", errors: [ServerError] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new "DELETE" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route.delete("/foo");
|
||||
* ```
|
||||
*/
|
||||
delete<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "DELETE", path, content: "json", errors: [ServerError] });
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Routes = {
|
||||
[key: string]: Routes | Route | RouteFn;
|
||||
};
|
||||
|
||||
export type RouteFn = (...args: any[]) => any;
|
||||
|
||||
type RouteState = {
|
||||
method: RouteMethod;
|
||||
path: string;
|
||||
meta?: RouteMeta;
|
||||
access?: RouteAccess;
|
||||
params?: ZodObject;
|
||||
query?: ZodObject;
|
||||
body?: ZodType;
|
||||
errors: ServerErrorClass[];
|
||||
response?: ZodType;
|
||||
handle?: HandleFn;
|
||||
};
|
||||
|
||||
export type RouteMeta = {
|
||||
openapi?: "internal" | "external";
|
||||
description?: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export type RouteAccess = "public" | "authenticated";
|
||||
|
||||
type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
|
||||
...args: TArgs
|
||||
) => TResponse extends ZodType
|
||||
? Promise<z.infer<TResponse> | Response | ServerError>
|
||||
: Promise<Response | ServerError | void>;
|
||||
|
||||
type ServerArgs<TState extends RouteState> = HasInputArgs<TState> extends true
|
||||
? [
|
||||
(TState["params"] extends ZodObject ? { params: z.output<TState["params"]> } : unknown) &
|
||||
(TState["query"] extends ZodObject ? { query: z.output<TState["query"]> } : unknown) &
|
||||
(TState["body"] extends ZodType ? { body: z.output<TState["body"]> } : unknown),
|
||||
ServerContext,
|
||||
]
|
||||
: [ServerContext];
|
||||
|
||||
type HasInputArgs<TState extends RouteState> = TState["params"] extends ZodObject
|
||||
? true
|
||||
: TState["query"] extends ZodObject
|
||||
? true
|
||||
: TState["body"] extends ZodType
|
||||
? true
|
||||
: false;
|
||||
|
||||
type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
7
platform/relay/mod.ts
Normal file
7
platform/relay/mod.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./adapters/http.ts";
|
||||
export * from "./libraries/adapter.ts";
|
||||
export * from "./libraries/client.ts";
|
||||
export * from "./libraries/context.ts";
|
||||
export * from "./libraries/errors.ts";
|
||||
export * from "./libraries/procedure.ts";
|
||||
export * from "./libraries/route.ts";
|
||||
17
platform/relay/package.json
Normal file
17
platform/relay/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@platform/relay",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./mod.ts",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@platform/auth": "workspace:*",
|
||||
"@platform/socket": "workspace:*",
|
||||
"@platform/supertokens": "workspace:*",
|
||||
"path-to-regexp": "8",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
}
|
||||
10
platform/routes/package.json
Normal file
10
platform/routes/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@platform/routes",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@platform/relay": "workspace:*",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
}
|
||||
8
platform/routes/session/search.ts
Normal file
8
platform/routes/session/search.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { route } from "@platform/relay";
|
||||
import z from "zod";
|
||||
|
||||
export default route.post("/api/v1/sessions/search").query({
|
||||
offset: z.number().min(0).default(0),
|
||||
limit: z.number().min(10).max(100).default(100),
|
||||
asc: z.boolean().default(true),
|
||||
});
|
||||
400
platform/server/api.ts
Normal file
400
platform/server/api.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { logger } from "@platform/logger";
|
||||
import {
|
||||
BadRequestError,
|
||||
context,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
NotImplementedError,
|
||||
type Route,
|
||||
type RouteMethod,
|
||||
ServerError,
|
||||
type ServerErrorResponse,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "@platform/relay";
|
||||
|
||||
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
export class Api {
|
||||
readonly #index = new Map<string, Route>();
|
||||
|
||||
/**
|
||||
* Route maps funneling registered routes to the specific methods supported by
|
||||
* the relay instance.
|
||||
*/
|
||||
readonly routes: Routes = {
|
||||
POST: [],
|
||||
GET: [],
|
||||
PUT: [],
|
||||
PATCH: [],
|
||||
DELETE: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* List of paths in the '${method} ${path}' format allowing us to quickly throw
|
||||
* errors if a duplicate route path is being added.
|
||||
*/
|
||||
readonly #paths = new Set<string>();
|
||||
|
||||
/**
|
||||
* Instantiate a new Api instance.
|
||||
*
|
||||
* @param routes - Initial list of routes to register with the api.
|
||||
*/
|
||||
constructor(routes: Route[] = []) {
|
||||
this.register(routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register relays with the API instance allowing for decoupled registration
|
||||
* of server side handling of relay contracts.
|
||||
*
|
||||
* @param routes - Relays to register with the instance.
|
||||
*/
|
||||
register(routes: Route[]): this {
|
||||
const methods: (keyof typeof this.routes)[] = [];
|
||||
for (const route of routes) {
|
||||
const path = `${route.method} ${route.path}`;
|
||||
if (this.#paths.has(path)) {
|
||||
throw new Error(`Router > Path ${path} already exists`);
|
||||
}
|
||||
this.#paths.add(path);
|
||||
this.routes[route.method].push(route);
|
||||
methods.push(route.method);
|
||||
this.#index.set(`${route.method} ${route.path}`, route);
|
||||
logger.prefix("API").info(`Registered ${route.method} ${route.path}`);
|
||||
}
|
||||
for (const method of methods) {
|
||||
this.routes[method].sort(byStaticPriority);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes request and returns a `Response` instance.
|
||||
*
|
||||
* @param request - REST request to pass to a route handler.
|
||||
*/
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// ### Route
|
||||
// Locate a route matching the incoming request method and path.
|
||||
|
||||
const resolved = this.#getResolvedRoute(request.method, url.pathname);
|
||||
if (resolved === undefined) {
|
||||
return toResponse(
|
||||
new NotFoundError(`Invalid routing path provided for ${request.url}`, {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
}),
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
// ### Handle
|
||||
// Execute request and return a response.
|
||||
|
||||
const response = await this.#getRouteResponse(resolved, request).catch((error) =>
|
||||
this.#getErrorResponse(error, request),
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to resolve a route based on the given method and pathname.
|
||||
*
|
||||
* @param method - HTTP method.
|
||||
* @param url - HTTP request url.
|
||||
*/
|
||||
#getResolvedRoute(method: string, url: string): ResolvedRoute | undefined {
|
||||
assertMethod(method);
|
||||
for (const route of this.routes[method]) {
|
||||
if (route.match(url) === true) {
|
||||
return { route, params: route.getParsedParams(url) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the request on the given route and return a `Response` instance.
|
||||
*
|
||||
* @param resolved - Route and paramter details resolved for the request.
|
||||
* @param request - Request instance to resolve.
|
||||
*/
|
||||
async #getRouteResponse({ route, params }: ResolvedRoute, request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// ### Args
|
||||
// Arguments is passed to every route handler and provides a suite of functionality
|
||||
// and request data.
|
||||
|
||||
const args: any[] = [];
|
||||
|
||||
// ### Input
|
||||
// Generate route input which contains a map fo params, query, and/or body. If
|
||||
// none of these are present then the input is not added to the final argument
|
||||
// context of the handler.
|
||||
|
||||
const input: {
|
||||
params?: object;
|
||||
query?: object;
|
||||
body?: unknown;
|
||||
} = {
|
||||
params: undefined,
|
||||
query: undefined,
|
||||
body: undefined,
|
||||
};
|
||||
|
||||
// ### Access
|
||||
// Check the access requirements of the route and run any additional checks
|
||||
// if nessesary before proceeding with further request handling.
|
||||
// 1. All routes needs access assignment, else we consider it an internal error.
|
||||
// 2. If access requires a session we throw Unauthorized if the request is not authenticated.
|
||||
// 3. If access is an array of access resources, we check that each resources can be
|
||||
// accessed by the request.
|
||||
|
||||
if (route.state.access === undefined) {
|
||||
return toResponse(
|
||||
new InternalServerError(`Route '${route.method} ${route.path}' is missing access assignment.`),
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
if (route.state.access === "authenticated" && context.isAuthenticated === false) {
|
||||
return toResponse(new UnauthorizedError(), request);
|
||||
}
|
||||
|
||||
// ### Params
|
||||
// If the route has params we want to coerce the values to the expected types.
|
||||
|
||||
if (route.state.params !== undefined) {
|
||||
const result = await route.state.params.safeParseAsync(params);
|
||||
if (result.success === false) {
|
||||
return toResponse(ValidationError.fromZod(result.error, "params", "Invalid request params"), request);
|
||||
}
|
||||
input.params = result.data;
|
||||
}
|
||||
|
||||
// ### Query
|
||||
// If the route has a query schema we need to validate and parse the query.
|
||||
|
||||
if (route.state.query !== undefined) {
|
||||
const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {});
|
||||
if (result.success === false) {
|
||||
return toResponse(ValidationError.fromZod(result.error, "query", "Invalid request query"), request);
|
||||
}
|
||||
input.query = result.data;
|
||||
}
|
||||
|
||||
// ### Body
|
||||
// If the route has a body schema we need to validate and parse the body.
|
||||
|
||||
if (route.state.body !== undefined) {
|
||||
const body = await this.#getRequestBody(request);
|
||||
const result = await route.state.body.safeParseAsync(body);
|
||||
if (result.success === false) {
|
||||
return toResponse(ValidationError.fromZod(result.error, "body", "Invalid request body"), request);
|
||||
}
|
||||
input.body = result.data;
|
||||
}
|
||||
|
||||
if (input.params !== undefined || input.query !== undefined || input.body !== undefined) {
|
||||
args.push(input);
|
||||
}
|
||||
|
||||
// ### Context
|
||||
// Request context pass to every route as the last argument.
|
||||
|
||||
args.push(context);
|
||||
|
||||
// ### Handler
|
||||
// Execute the route handler and apply the result.
|
||||
|
||||
if (route.state.handle === undefined) {
|
||||
return toResponse(new NotImplementedError(`Path '${route.method} ${route.path}' is not implemented.`), request);
|
||||
}
|
||||
|
||||
return toResponse(await route.state.handle(...args), request);
|
||||
}
|
||||
|
||||
#getErrorResponse(error: unknown, request: Request): Response {
|
||||
if (error instanceof ServerError) {
|
||||
return toResponse(error, request);
|
||||
}
|
||||
logger.error(error);
|
||||
if (error instanceof Error) {
|
||||
return toResponse(new InternalServerError(error.message), request);
|
||||
}
|
||||
return toResponse(new InternalServerError(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves request body and returns it.
|
||||
*
|
||||
* @param request - Request to resolve body from.
|
||||
* @param files - Files to populate if present.
|
||||
*/
|
||||
async #getRequestBody(request: Request): Promise<Record<string, unknown>> {
|
||||
let body: Record<string, unknown> = {};
|
||||
|
||||
const type = request.headers.get("content-type");
|
||||
if (!type || request.method === "GET") {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (type.includes("json")) {
|
||||
body = await request.json();
|
||||
}
|
||||
|
||||
if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
for (const [name, value] of Array.from(formData.entries())) {
|
||||
body[name] = value;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
throw new BadRequestError(`Malformed FormData`, { error });
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Assert that the given method string is a valid routing method.
|
||||
*
|
||||
* @param candidate - Method candidate.
|
||||
*/
|
||||
function assertMethod(candidate: string): asserts candidate is RouteMethod {
|
||||
if (!SUPPORTED_MEHODS.includes(candidate)) {
|
||||
throw new Error(`Router > Unsupported method '${candidate}'`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorting method for routes to ensure that static properties takes precedence
|
||||
* for when a route is matched against incoming requests.
|
||||
*
|
||||
* @param a - Route A
|
||||
* @param b - Route B
|
||||
*/
|
||||
function byStaticPriority(a: Route, b: Route) {
|
||||
const aSegments = a.path.split("/");
|
||||
const bSegments = b.path.split("/");
|
||||
|
||||
const maxLength = Math.max(aSegments.length, bSegments.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const aSegment = aSegments[i] || "";
|
||||
const bSegment = bSegments[i] || "";
|
||||
|
||||
const isADynamic = aSegment.startsWith(":");
|
||||
const isBDynamic = bSegment.startsWith(":");
|
||||
|
||||
if (isADynamic !== isBDynamic) {
|
||||
return isADynamic ? 1 : -1;
|
||||
}
|
||||
|
||||
if (isADynamic === false && aSegment !== bSegment) {
|
||||
return aSegment.localeCompare(bSegment);
|
||||
}
|
||||
}
|
||||
|
||||
return a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and return query object from the provided search parameters, or undefined
|
||||
* if the search parameters does not have any entries.
|
||||
*
|
||||
* @param searchParams - Search params to create a query object from.
|
||||
*/
|
||||
function toQuery(searchParams: URLSearchParams): object | undefined {
|
||||
if (searchParams.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a server side request result and returns a fetch Response.
|
||||
*
|
||||
* @param result - Result to send back as a Response.
|
||||
* @param request - Request instance.
|
||||
*/
|
||||
export function toResponse(result: unknown, request: Request): Response {
|
||||
const method = request.method;
|
||||
|
||||
if (result instanceof Response) {
|
||||
if (method === "HEAD") {
|
||||
return new Response(null, {
|
||||
status: result.status,
|
||||
statusText: result.statusText,
|
||||
headers: new Headers(result.headers),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (result instanceof ServerError) {
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
code: result.code,
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
data: result.data,
|
||||
},
|
||||
} satisfies ServerErrorResponse);
|
||||
|
||||
return new Response(method === "HEAD" ? null : body, {
|
||||
statusText: result.message || "Internal Server Error",
|
||||
status: result.status || 500,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
data: result ?? null,
|
||||
});
|
||||
|
||||
return new Response(method === "HEAD" ? null : body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Routes = {
|
||||
POST: Route[];
|
||||
GET: Route[];
|
||||
PUT: Route[];
|
||||
PATCH: Route[];
|
||||
DELETE: Route[];
|
||||
};
|
||||
|
||||
type ResolvedRoute = {
|
||||
route: Route;
|
||||
params: any;
|
||||
};
|
||||
40
platform/server/modules.ts
Normal file
40
platform/server/modules.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Route } from "@platform/relay";
|
||||
|
||||
/**
|
||||
* Resolve and return all routes that has been created under any 'routes'
|
||||
* folders that can be found under the given path.
|
||||
*
|
||||
* If the filter is empty, all paths are resolved, otherwise only paths
|
||||
* declared in the array is resolved.
|
||||
*
|
||||
* @param path - Path to resolve routes from.
|
||||
* @param filter - List of modules to include.
|
||||
* @param routes - List of routes that has been resolved.
|
||||
*/
|
||||
export async function resolveRoutes(path: string, routes: Route[] = []): Promise<Route[]> {
|
||||
for await (const entry of Deno.readDir(path)) {
|
||||
if (entry.isDirectory === true) {
|
||||
await loadRoutes(`${path}/${entry.name}`, routes, [name]);
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
async function loadRoutes(path: string, routes: Route[], modules: string[]): Promise<void> {
|
||||
for await (const entry of Deno.readDir(path)) {
|
||||
if (entry.isDirectory === true) {
|
||||
await loadRoutes(`${path}/${entry.name}`, routes, [...modules, entry.name]);
|
||||
} else {
|
||||
if (!entry.name.endsWith(".ts") || entry.name.endsWith("i9n.ts")) {
|
||||
continue;
|
||||
}
|
||||
const { default: route } = (await import(`${path}/${entry.name}`)) as { default: Route };
|
||||
if (route instanceof Route === false) {
|
||||
throw new Error(
|
||||
`Router Violation: Could not load '${path}/${entry.name}' as it does not export a default Route instance.`,
|
||||
);
|
||||
}
|
||||
routes.push(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
platform/server/package.json
Normal file
16
platform/server/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@platform/server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "types.d.ts",
|
||||
"dependencies": {
|
||||
"@platform/auth": "workspace:*",
|
||||
"@platform/logger": "workspace:*",
|
||||
"@platform/relay": "workspace:*",
|
||||
"@platform/socket": "workspace:*",
|
||||
"@platform/storage": "workspace:*",
|
||||
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
}
|
||||
69
platform/server/server.ts
Normal file
69
platform/server/server.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import "./types.ts";
|
||||
|
||||
import { context, InternalServerError } from "@platform/relay";
|
||||
import { getStorageContext, storage } from "@platform/storage";
|
||||
|
||||
export default {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
bootstrap: async (): Promise<void> => {
|
||||
Object.defineProperties(context, {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
request: {
|
||||
get() {
|
||||
const request = storage.getStore()?.request;
|
||||
if (request === undefined) {
|
||||
throw new InternalServerError("Storage missing 'request' assignment.");
|
||||
}
|
||||
return request;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
response: {
|
||||
get() {
|
||||
const response = storage.getStore()?.response;
|
||||
if (response === undefined) {
|
||||
throw new InternalServerError("Storage missing 'response' assignment.");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
info: {
|
||||
get() {
|
||||
const info = storage.getStore()?.info;
|
||||
if (info === undefined) {
|
||||
throw new InternalServerError("Storage missing 'info' assignment.");
|
||||
}
|
||||
return info;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
resolve: async (request: Request): Promise<void> => {
|
||||
const context = getStorageContext();
|
||||
context.request = {
|
||||
headers: request.headers,
|
||||
};
|
||||
context.response = {
|
||||
headers: new Headers(),
|
||||
};
|
||||
context.info = {
|
||||
method: request.url,
|
||||
start: Date.now(),
|
||||
};
|
||||
},
|
||||
};
|
||||
50
platform/server/socket.ts
Normal file
50
platform/server/socket.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { logger } from "@platform/logger";
|
||||
import { context } from "@platform/relay";
|
||||
import { storage } from "@platform/storage";
|
||||
import { toJsonRpc } from "@valkyr/json-rpc";
|
||||
|
||||
import type { Api } from "./api.ts";
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
export function upgradeWebSocket(request: Request, _api: Api) {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
logger.prefix("Socket").info("socket connected", {});
|
||||
context.sockets.add(socket);
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
logger.prefix("Socket").info("socket disconnected", {});
|
||||
context.sockets.del(socket);
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (event.data === "ping") {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = toJsonRpc(event.data);
|
||||
|
||||
logger.prefix("Socket").info(message);
|
||||
|
||||
storage.run({}, async () => {
|
||||
// api
|
||||
// .send(body)
|
||||
// .then((response) => {
|
||||
// if (response !== undefined) {
|
||||
// logger.info({ response });
|
||||
// socket.send(JSON.stringify(response));
|
||||
// }
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// logger.info({ error });
|
||||
// socket.send(JSON.stringify(error));
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
45
platform/server/types.ts
Normal file
45
platform/server/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import "@platform/relay";
|
||||
import "@platform/storage";
|
||||
|
||||
declare module "@platform/storage" {
|
||||
interface StorageContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
request?: {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
response?: {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
info?: {
|
||||
method: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@platform/relay" {
|
||||
interface ServerContext {
|
||||
request: {
|
||||
headers: Headers;
|
||||
};
|
||||
response: {
|
||||
headers: Headers;
|
||||
};
|
||||
info: {
|
||||
method: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
81
platform/socket/channels.ts
Normal file
81
platform/socket/channels.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Params } from "@valkyr/json-rpc";
|
||||
|
||||
import { SocketRegistry } from "./sockets.ts";
|
||||
|
||||
export class Channels {
|
||||
readonly #channels = new Map<string, SocketRegistry>();
|
||||
|
||||
/**
|
||||
* Add a new channel.
|
||||
*
|
||||
* @param channel
|
||||
*/
|
||||
add(channel: string): this {
|
||||
this.#channels.set(channel, new SocketRegistry());
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a channel.
|
||||
*
|
||||
* @param channel
|
||||
*/
|
||||
del(channel: string): this {
|
||||
this.#channels.delete(channel);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add socket to the given channel. If the channel does not exist it is
|
||||
* automatically created.
|
||||
*
|
||||
* @param channel - Channel to add socket to.
|
||||
* @param socket - Socket to add to the channel.
|
||||
*/
|
||||
join(channel: string, socket: WebSocket): this {
|
||||
const sockets = this.#channels.get(channel);
|
||||
if (sockets === undefined) {
|
||||
this.#channels.set(channel, new SocketRegistry().add(socket));
|
||||
} else {
|
||||
sockets.add(socket);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a socket from the given channel.
|
||||
*
|
||||
* @param channel - Channel to leave.
|
||||
* @param socket - Socket to remove from the channel.
|
||||
*/
|
||||
leave(channel: string, socket: WebSocket): this {
|
||||
this.#channels.get(channel)?.del(socket);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC notification to all sockets in given channel.
|
||||
*
|
||||
* @param channel - Channel to emit method to.
|
||||
* @param method - Method to send the notification to.
|
||||
* @param params - Message data to send to the clients.
|
||||
*/
|
||||
notify(channel: string, method: string, params: Params): this {
|
||||
this.#channels.get(channel)?.notify(method, params);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transmits data to all registered WebSocket connections in the given channel.
|
||||
* Data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView.
|
||||
*
|
||||
* @param channel - Channel to emit message to.
|
||||
* @param data - Data to send to each connected socket in the channel.
|
||||
*/
|
||||
send(channel: string, data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
|
||||
this.#channels.get(channel)?.send(data);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export const channels = new Channels();
|
||||
14
platform/socket/package.json
Normal file
14
platform/socket/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@platform/socket",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "types.d.ts",
|
||||
"dependencies": {
|
||||
"@platform/auth": "workspace:*",
|
||||
"@platform/logger": "workspace:*",
|
||||
"@platform/relay": "workspace:*",
|
||||
"@platform/storage": "workspace:*",
|
||||
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0"
|
||||
}
|
||||
}
|
||||
38
platform/socket/server.ts
Normal file
38
platform/socket/server.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import "./types.d.ts";
|
||||
|
||||
import { context, InternalServerError } from "@platform/relay";
|
||||
import { getStorageContext, storage } from "@platform/storage";
|
||||
|
||||
import { SocketRegistry } from "./sockets.ts";
|
||||
|
||||
export const sockets = new SocketRegistry();
|
||||
|
||||
export default {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
bootstrap: async (): Promise<void> => {
|
||||
Object.defineProperties(context, {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
sockets: {
|
||||
get() {
|
||||
const sockets = storage.getStore()?.sockets;
|
||||
if (sockets === undefined) {
|
||||
throw new InternalServerError("Sockets not defined.");
|
||||
}
|
||||
return sockets;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
resolve: async (): Promise<void> => {
|
||||
const context = getStorageContext();
|
||||
context.sockets = sockets;
|
||||
},
|
||||
};
|
||||
49
platform/socket/sockets.ts
Normal file
49
platform/socket/sockets.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Params } from "@valkyr/json-rpc";
|
||||
|
||||
export class SocketRegistry {
|
||||
readonly #sockets = new Set<WebSocket>();
|
||||
|
||||
/**
|
||||
* Add a socket to the pool.
|
||||
*
|
||||
* @param socket - WebSocket to add.
|
||||
*/
|
||||
add(socket: WebSocket): this {
|
||||
this.#sockets.add(socket);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a socket from the pool.
|
||||
*
|
||||
* @param socket - WebSocket to remove.
|
||||
*/
|
||||
del(socket: WebSocket): this {
|
||||
this.#sockets.delete(socket);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC notification to all connected sockets.
|
||||
*
|
||||
* @param method - Method to send the notification to.
|
||||
* @param params - Message data to send to the clients.
|
||||
*/
|
||||
notify(method: string, params: Params): this {
|
||||
this.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transmits data to all registered WebSocket connections. Data can be a string,
|
||||
* a Blob, an ArrayBuffer, or an ArrayBufferView.
|
||||
*
|
||||
* @param data - Data to send to each connected socket.
|
||||
*/
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): this {
|
||||
this.#sockets.forEach((socket) => {
|
||||
socket.send(data);
|
||||
});
|
||||
return this;
|
||||
}
|
||||
}
|
||||
22
platform/socket/types.d.ts
vendored
Normal file
22
platform/socket/types.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import "@platform/relay";
|
||||
import "@platform/storage";
|
||||
|
||||
import { SocketRegistry } from "./sockets.ts";
|
||||
|
||||
declare module "@platform/storage" {
|
||||
interface StorageContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
sockets?: SocketRegistry;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@platform/relay" {
|
||||
export interface ServerContext {
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
sockets: SocketRegistry;
|
||||
}
|
||||
}
|
||||
17
platform/spec/audit/actor.ts
Normal file
17
platform/spec/audit/actor.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import z from "zod";
|
||||
|
||||
import { AuditUserSchema, AuditUserType } from "./user.ts";
|
||||
|
||||
export const AuditActorSchema = z.object({
|
||||
user: AuditUserSchema,
|
||||
});
|
||||
|
||||
export const auditors = {
|
||||
system: AuditActorSchema.parse({
|
||||
user: {
|
||||
typeId: AuditUserType.System,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export type AuditActor = z.infer<typeof AuditActorSchema>;
|
||||
17
platform/spec/audit/user.ts
Normal file
17
platform/spec/audit/user.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import z from "zod";
|
||||
|
||||
export enum AuditUserType {
|
||||
Unknown = 0,
|
||||
Identity = 1,
|
||||
System = 2,
|
||||
Service = 3,
|
||||
Other = 99,
|
||||
}
|
||||
|
||||
export const AuditUserSchema = z.object({
|
||||
typeId: z.enum(AuditUserType).describe("The account type identifier."),
|
||||
uid: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("The unique user identifier. For example, the Windows user SID, ActiveDirectory DN or AWS user ARN."),
|
||||
});
|
||||
7
platform/spec/auth/errors.ts
Normal file
7
platform/spec/auth/errors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BadRequestError } from "@platform/relay";
|
||||
|
||||
export class AuthenticationStrategyPayloadError extends BadRequestError {
|
||||
constructor() {
|
||||
super("Provided authentication payload is not recognized.");
|
||||
}
|
||||
}
|
||||
44
platform/spec/auth/strategies.ts
Normal file
44
platform/spec/auth/strategies.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const PasskeyStrategySchema = z.object({
|
||||
type: z.literal("passkey").describe("Authentication strategy type for WebAuthn/Passkey"),
|
||||
id: z.string().describe("Base64URL encoded credential ID"),
|
||||
rawId: z.string().describe("Raw credential ID as base64URL encoded string"),
|
||||
response: z
|
||||
.object({
|
||||
clientDataJSON: z.string().describe("Base64URL encoded client data JSON"),
|
||||
authenticatorData: z.string().describe("Base64URL encoded authenticator data"),
|
||||
signature: z.string().optional().describe("Signature for authentication responses"),
|
||||
userHandle: z.string().optional().describe("Optional user handle identifier"),
|
||||
attestationObject: z.string().optional().describe("Attestation object for registration responses"),
|
||||
})
|
||||
.describe("WebAuthn response data"),
|
||||
clientExtensionResults: z
|
||||
.record(z.string(), z.unknown())
|
||||
.default({})
|
||||
.describe("Results from WebAuthn extension inputs"),
|
||||
authenticatorAttachment: z
|
||||
.enum(["platform", "cross-platform"])
|
||||
.optional()
|
||||
.describe("Type of authenticator used (platform or cross-platform)"),
|
||||
});
|
||||
|
||||
export const EmailStrategySchema = z.object({
|
||||
type: z.literal("email").describe("Authentication strategy type for email"),
|
||||
email: z.email().describe("User's email address for authentication"),
|
||||
});
|
||||
|
||||
export const PasswordStrategySchema = z.object({
|
||||
type: z.literal("password").describe("Authentication strategy type for password"),
|
||||
alias: z.string().describe("User alias (username or email)"),
|
||||
password: z.string().describe("User's password"),
|
||||
});
|
||||
|
||||
export const StrategySchema = z
|
||||
.union([PasskeyStrategySchema, EmailStrategySchema, PasswordStrategySchema])
|
||||
.describe("Union of all available authentication strategy schemas");
|
||||
|
||||
export type PasskeyStrategy = z.infer<typeof PasskeyStrategySchema>;
|
||||
export type EmailStrategy = z.infer<typeof EmailStrategySchema>;
|
||||
export type PasswordStrategy = z.infer<typeof PasswordStrategySchema>;
|
||||
export type Strategy = z.infer<typeof StrategySchema>;
|
||||
11
platform/spec/package.json
Normal file
11
platform/spec/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@platform/spec",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@platform/models": "workspace:*",
|
||||
"@platform/relay": "workspace:*",
|
||||
"zod": "4.1.12"
|
||||
}
|
||||
}
|
||||
13
platform/storage/package.json
Normal file
13
platform/storage/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@platform/storage",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./storage.ts",
|
||||
"exports": {
|
||||
".": "./storage.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@platform/relay": "workspace:*"
|
||||
}
|
||||
}
|
||||
22
platform/storage/storage.ts
Normal file
22
platform/storage/storage.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import { InternalServerError } from "@platform/relay";
|
||||
|
||||
export const storage = new AsyncLocalStorage<StorageContext>();
|
||||
|
||||
/**
|
||||
* TODO ...
|
||||
*/
|
||||
export function getStorageContext(): StorageContext {
|
||||
const store = storage.getStore();
|
||||
if (store === undefined) {
|
||||
throw new InternalServerError(
|
||||
"Storage 'store' missing, make sure to resolve within a 'node:async_hooks' wrapped context.",
|
||||
);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export interface StorageContext {
|
||||
id: string;
|
||||
}
|
||||
54
platform/vault/hmac.ts
Normal file
54
platform/vault/hmac.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Hash a value with given secret.
|
||||
*
|
||||
* @param value - Value to hash.
|
||||
* @param secret - Secret to hash the value against.
|
||||
*/
|
||||
export async function hash(value: string, secret: string): Promise<string> {
|
||||
const key = await getImportKey(secret, ["sign"]);
|
||||
const encoder = new TextEncoder();
|
||||
const valueData = encoder.encode(value);
|
||||
const signature = await crypto.subtle.sign("HMAC", key, valueData);
|
||||
return bufferToHex(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the given value results in the expected hash using the provided secret.
|
||||
*
|
||||
* @param value - Value to verify.
|
||||
* @param expectedHash - Expected hash value.
|
||||
* @param secret - Secret used to hash the value.
|
||||
*/
|
||||
export async function verify(value: string, expectedHash: string, secret: string): Promise<boolean> {
|
||||
const key = await getImportKey(secret, ["verify"]);
|
||||
const encoder = new TextEncoder();
|
||||
const valueData = encoder.encode(value);
|
||||
const signature = hexToBuffer(expectedHash);
|
||||
return crypto.subtle.verify("HMAC", key, signature, valueData);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Utilities
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
async function getImportKey(secret: string, usages: KeyUsage[]): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
return crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: { name: "SHA-256" } }, false, usages);
|
||||
}
|
||||
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function hexToBuffer(hex: string): ArrayBuffer {
|
||||
const match = hex.match(/.{1,2}/g);
|
||||
if (match === null) {
|
||||
return new Uint8Array().buffer;
|
||||
}
|
||||
return new Uint8Array(match.map((byte) => parseInt(byte, 16))).buffer;
|
||||
}
|
||||
134
platform/vault/key-pair.ts
Normal file
134
platform/vault/key-pair.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as Jose from "jose";
|
||||
|
||||
export class KeyPair {
|
||||
readonly #public: PublicKey;
|
||||
readonly #private: PrivateKey;
|
||||
readonly #algorithm: string;
|
||||
|
||||
constructor({ publicKey, privateKey }: Jose.GenerateKeyPairResult, algorithm: string) {
|
||||
this.#public = new PublicKey(publicKey);
|
||||
this.#private = new PrivateKey(privateKey);
|
||||
this.#algorithm = algorithm;
|
||||
}
|
||||
|
||||
get public() {
|
||||
return this.#public;
|
||||
}
|
||||
|
||||
get private() {
|
||||
return this.#private;
|
||||
}
|
||||
|
||||
get algorithm() {
|
||||
return this.#algorithm;
|
||||
}
|
||||
|
||||
async toJSON() {
|
||||
return {
|
||||
publicKey: await this.public.toString(),
|
||||
privateKey: await this.private.toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicKey {
|
||||
readonly #key: Jose.CryptoKey;
|
||||
|
||||
constructor(key: Jose.CryptoKey) {
|
||||
this.#key = key;
|
||||
}
|
||||
|
||||
get key(): Jose.CryptoKey {
|
||||
return this.#key;
|
||||
}
|
||||
|
||||
async toString() {
|
||||
return Jose.exportSPKI(this.#key);
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivateKey {
|
||||
readonly #key: Jose.CryptoKey;
|
||||
|
||||
constructor(key: Jose.CryptoKey) {
|
||||
this.#key = key;
|
||||
}
|
||||
|
||||
get key(): Jose.CryptoKey {
|
||||
return this.#key;
|
||||
}
|
||||
|
||||
async toString() {
|
||||
return Jose.exportPKCS8(this.#key);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Factories
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a new key pair using the provided algorithm.
|
||||
*
|
||||
* @param algorithm - Algorithm to use for key generation.
|
||||
*
|
||||
* @returns new key pair instance
|
||||
*/
|
||||
export async function createKeyPair(algorithm: string): Promise<KeyPair> {
|
||||
return new KeyPair(await Jose.generateKeyPair(algorithm, { extractable: true }), algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a keypair from a previously exported keypair into a new KeyPair instance.
|
||||
*
|
||||
* @param keyPair - KeyPair to load into a new keyPair instance.
|
||||
* @param algorithm - Algorithm to use for key generation.
|
||||
*
|
||||
* @returns new key pair instance
|
||||
*/
|
||||
export async function loadKeyPair({ publicKey, privateKey }: ExportedKeyPair, algorithm: string): Promise<KeyPair> {
|
||||
return new KeyPair(
|
||||
{
|
||||
publicKey: await importPublicKey(publicKey, algorithm),
|
||||
privateKey: await importPrivateKey(privateKey, algorithm),
|
||||
},
|
||||
algorithm,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new Jose.KeyLike instance from a public key string.
|
||||
*
|
||||
* @param publicKey - Public key string.
|
||||
* @param algorithm - Algorithm to used for key generation.
|
||||
*
|
||||
* @returns new Jose.KeyLike instance
|
||||
*/
|
||||
export async function importPublicKey(publicKey: string, algorithm: string): Promise<Jose.CryptoKey> {
|
||||
return Jose.importSPKI(publicKey, algorithm, { extractable: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* get a new Jose.KeyLike instance from a private key string.
|
||||
*
|
||||
* @param privateKey - Private key string.
|
||||
* @param algorithm - Algorithm to used for key generation.
|
||||
*
|
||||
* @returns new Jose.KeyLike instance
|
||||
*/
|
||||
export async function importPrivateKey(privateKey: string, algorithm: string): Promise<Jose.CryptoKey> {
|
||||
return Jose.importPKCS8(privateKey, algorithm, { extractable: true });
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type ExportedKeyPair = {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
10
platform/vault/package.json
Normal file
10
platform/vault/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@platform/vault",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"jose": "6.1.0",
|
||||
"nanoid": "5.1.5"
|
||||
}
|
||||
}
|
||||
91
platform/vault/vault.ts
Normal file
91
platform/vault/vault.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as Jose from "jose";
|
||||
|
||||
import {
|
||||
createKeyPair,
|
||||
type ExportedKeyPair,
|
||||
importPrivateKey,
|
||||
importPublicKey,
|
||||
type KeyPair,
|
||||
loadKeyPair,
|
||||
} from "./key-pair.ts";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Security Settings
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const VAULT_ALGORITHM = "ECDH-ES+A256KW";
|
||||
const VAULT_ENCRYPTION = "A256GCM";
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Vault
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export class Vault {
|
||||
#keyPair: KeyPair;
|
||||
|
||||
constructor(keyPair: KeyPair) {
|
||||
this.#keyPair = keyPair;
|
||||
}
|
||||
|
||||
get keys() {
|
||||
return this.#keyPair;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enecrypt the given value with the vaults key pair.
|
||||
*
|
||||
* @param value - Value to encrypt.
|
||||
*/
|
||||
async encrypt<T extends Record<string, unknown> | unknown[] | string>(value: T): Promise<string> {
|
||||
const text = new TextEncoder().encode(JSON.stringify(value));
|
||||
return new Jose.CompactEncrypt(text)
|
||||
.setProtectedHeader({
|
||||
alg: VAULT_ALGORITHM,
|
||||
enc: VAULT_ENCRYPTION,
|
||||
})
|
||||
.encrypt(this.#keyPair.public.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the given cypher text with the vaults key pair.
|
||||
*
|
||||
* @param cypherText - String to decrypt.
|
||||
*/
|
||||
async decrypt<T>(cypherText: string): Promise<T> {
|
||||
const { plaintext } = await Jose.compactDecrypt(cypherText, this.#keyPair.private.key);
|
||||
return JSON.parse(new TextDecoder().decode(plaintext));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Factories
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export async function createVault(): Promise<Vault> {
|
||||
return new Vault(await createKeyPair(VAULT_ALGORITHM));
|
||||
}
|
||||
|
||||
export async function importVault(keyPair: ExportedKeyPair): Promise<Vault> {
|
||||
return new Vault(await loadKeyPair(keyPair, VAULT_ALGORITHM));
|
||||
}
|
||||
|
||||
export async function encrypt<T extends Record<string, unknown> | unknown[] | string>(value: T, publicKey: string) {
|
||||
const text = new TextEncoder().encode(JSON.stringify(value));
|
||||
return new Jose.CompactEncrypt(text)
|
||||
.setProtectedHeader({
|
||||
alg: VAULT_ALGORITHM,
|
||||
enc: VAULT_ENCRYPTION,
|
||||
})
|
||||
.encrypt(await importPublicKey(publicKey, VAULT_ALGORITHM));
|
||||
}
|
||||
|
||||
export async function decrypt<T>(cypherText: string, privateKey: string): Promise<T> {
|
||||
const { plaintext } = await Jose.compactDecrypt(cypherText, await importPrivateKey(privateKey, VAULT_ALGORITHM));
|
||||
return JSON.parse(new TextDecoder().decode(plaintext));
|
||||
}
|
||||
Reference in New Issue
Block a user