201 lines
6.5 KiB
TypeScript
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 });
|
|
}
|