Template
1
0

feat: checkpoint

This commit is contained in:
2025-11-23 22:57:43 +01:00
parent 7df57522d2
commit 5d45e273ee
160 changed files with 10160 additions and 1476 deletions

400
platform/server/api.ts Normal file
View 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;
};

View 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);
}
}
}

View 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
View 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
View 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
View 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;
};
}
}