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; /** 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 { for (const integration of this.integrations) if (integration.transform) asset = await integration.transform(asset); return asset; } async analyzePage(page: { route: string; html: string; }): Promise> { const reports: Record = {}; 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 { const app = new App(options); await app.init(); return app; } /** Runs the integrations and instantiates the controllers. */ async init(): Promise { 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 >[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, ): void { this.router.raw(method, path, handler); } /** Handles a fetch request and returns the platform response. */ async fetch(request: Request): Promise { 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 { 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 }); }