muddle/core/app.ts
2026-10-23 08:53:07 -04:00

201 lines
6.5 KiB
TypeScript

import { ModuleRegistry } from "./modules.ts";
import { Router, type ViewDescriptor } from "./router.ts";
import { createRequestContext, parseRequestBody } from "./http.ts";
import { toFormFields } from "./form.ts";
import type { Type } from "./container.ts";
import type { HttpResponse } from "../contracts/typegen/http/index.ts";
import type { RequestContext } from "../contracts/typegen/http/request.ts";
import type { RenderedView } from "../contracts/typegen/http/response.ts";
import type {
Integration,
IntegrationContext,
Mounts,
ResolvedConfig,
BuildAsset,
} from "../contracts/index.ts";
import type { ServerBuildContext } from "./context.ts";
import type { RoutePipeline } from "./pipeline.ts";
/** Renders the templates of the application. */
export interface ViewRenderer {
renderDescriptor(
descriptor: ViewDescriptor,
context: RequestContext,
): Promise<string | RenderedView> | string | RenderedView;
/** Registry of named components, mount targets of the integrations. */
components?: Map<string, { name: string; component: unknown; deferred: boolean }>;
}
export interface AppOptions {
/** Root modules of the application. */
modules?: Type[];
/** Renderer used for the templates of the routes. */
views?: ViewRenderer;
/** Resolved configuration of the project. */
config?: ResolvedConfig;
/** Integrations of the project. */
integrations?: Integration[];
server?: ServerBuildContext;
pipeline?: RoutePipeline;
}
export class App {
readonly registry: ModuleRegistry;
readonly router: Router;
private readonly config?: ResolvedConfig;
private readonly integrations: Integration[];
private readonly views: ViewRenderer | undefined;
private readonly server;
constructor(options: AppOptions = {}) {
this.config = options.config;
this.integrations = options.integrations ?? [];
this.views = options.views;
this.server = options.server?.build() ?? { headers: {}, locals: {} };
this.registry = new ModuleRegistry();
const views = options.views;
this.router = new Router({
pipeline: options.pipeline,
renderView: views
? (descriptor, context) => views.renderDescriptor(descriptor, context)
: undefined,
});
for (const moduleType of options.modules ?? []) {
this.registry.register(moduleType);
}
}
get resolvedConfig(): ResolvedConfig | undefined {
return this.config;
}
async transformAsset(asset: BuildAsset): Promise<BuildAsset> {
for (const integration of this.integrations) if (integration.transform) asset = await integration.transform(asset);
return asset;
}
async analyzePage(page: { route: string; html: string }): Promise<Record<string, unknown>> {
const reports: Record<string, unknown> = {};
for (const integration of this.integrations) if (integration.analyze) reports[integration.name] = await integration.analyze(page);
return reports;
}
/** Creates the application and awaits the asynchronous providers. */
static async create(options: AppOptions = {}): Promise<App> {
const app = new App(options);
await app.init();
return app;
}
/** Runs the integrations and instantiates the controllers. */
async init(): Promise<void> {
const integrationContext: IntegrationContext = {
config: this.config ?? ({} as ResolvedConfig),
route: (method, path, handler) => this.router.raw(method, path, handler),
loader: (name, loader) => {
if (this.config) this.config.loaders[name] = loader;
},
};
for (const integration of this.integrations) {
await integration.setup?.(integrationContext);
for (const mount of integration.mounts ?? []) {
if (mount.views && this.views) {
await mount.views(this.views as unknown as Parameters<NonNullable<Mounts["views"]>>[0]);
}
if (mount.routes) {
await mount.routes((method, path, handler) =>
this.router.raw(method, path, (_request, _context) => handler(_request)),
);
}
if (mount.styles) {
for (const [path, value] of Object.entries(mount.styles)) {
this.router.raw("GET", path, async () =>
new Response(typeof value === "function" ? await value() : value, {
headers: { "content-type": "text/css; charset=utf-8" },
}),
);
}
}
}
}
await this.registry.bootstrapAsync();
for (const { context, controller } of this.registry.controllers()) {
const instance = await context.container.resolveAsync(controller);
this.router.register(controller, instance);
}
for (const integration of this.integrations) {
await integration.ready?.();
}
}
/** Adds a route that is not bound to a controller. */
route(
method: string,
path: string,
handler: (
request: Request,
context: RequestContext,
) => Promise<Response> | Response,
): void {
this.router.raw(method, path, handler);
}
/** Handles a fetch request and returns the platform response. */
async fetch(request: Request): Promise<Response> {
const context = createRequestContext(request);
Object.assign(context.locals, this.server.locals);
const response = await this.handle(request, context);
const fetchResponse = toFetchResponse(response);
for (const [name, value] of Object.entries(this.server.headers)) fetchResponse.headers.set(name, value);
return fetchResponse;
}
/** Handles a request with a pre-built context. */
async handle(
request: Request,
context: RequestContext,
): Promise<HttpResponse> {
for (const integration of this.integrations) await integration.request?.(context);
context.body = await parseRequestBody(request);
context.form = toFormFields(
context.body &&
typeof context.body === "object" &&
!Array.isArray(context.body)
? context.body
: undefined,
);
return this.router.handle(context);
}
}
/** Converts the framework response into a fetch `Response`. */
export function toFetchResponse(response: HttpResponse): Response {
const headers = new Headers();
for (const [name, value] of Object.entries(response.headers ?? {})) {
headers.set(name, value);
}
const status = response.status;
const nullBody = status === 204 || status === 304;
const body = response.body;
if (nullBody || body === undefined || body === null) {
return new Response(null, { status, headers });
}
if (typeof body === "string") {
return new Response(body, { status, headers });
}
if (body instanceof Uint8Array) {
return new Response(body, { status, headers });
}
if (body instanceof ArrayBuffer) {
return new Response(body, { status, headers });
}
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
return new Response(body, { status, headers });
}
return new Response(JSON.stringify(body), { status, headers });
}