@coloneldev/framework 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@coloneldev/framework",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ }
15
+ }
File without changes
@@ -0,0 +1,56 @@
1
+ import type { HttpRequestProps } from "./interfaces/HttpRequestPropsInterface";
2
+
3
+ export class HttpRequest {
4
+ readonly method: string;
5
+ readonly url: URL;
6
+ readonly headers: Headers;
7
+ readonly query: URLSearchParams;
8
+ readonly body: unknown;
9
+ private _params: Record<string, string> = {};
10
+
11
+ constructor (props: HttpRequestProps) {
12
+ this.method = props.method.toUpperCase();
13
+ this.url = new URL(props.url);
14
+ this.headers = props.headers;
15
+ this.query = props.query;
16
+ this.body = props.body;
17
+ }
18
+
19
+ json <T = any>(): T | null {
20
+ return typeof this.body === "object" ? (this.body as T) : null;
21
+ }
22
+
23
+ header (name: string): string | null {
24
+ return this.headers.get(name);
25
+ }
26
+
27
+ input (key: string): unknown {
28
+ return this.query.get(key);
29
+ }
30
+
31
+ params (key: string): string | undefined {
32
+ return this._params[key];
33
+ }
34
+
35
+ path (): string {
36
+ return new URL (this.url).pathname;
37
+ }
38
+
39
+ all (): Record<string, unknown> {
40
+ const queryObj = Object.fromEntries(this.query.entries());
41
+ const bodyObj = typeof this.body === "object" && this.body !== null ?
42
+ this.body as Record<string, unknown> : {};
43
+
44
+ return { ...queryObj, ...bodyObj };
45
+ }
46
+
47
+ isJson (): boolean {
48
+ return (this.headers.get("content-type") || "").includes("application/json");
49
+ }
50
+
51
+ setParams (params: Record<string, string>) {
52
+ this._params = params;
53
+ }
54
+
55
+
56
+ }
@@ -0,0 +1,11 @@
1
+ export const redirect = (
2
+ to: string ,
3
+ status: 301 | 302 | 303 | 307 | 308 = 302): Response => {
4
+ console.info(`Redirecting to ${to} with status ${status}`)
5
+ return new Response(null, {
6
+ status,
7
+ headers: {
8
+ "Location": to
9
+ },
10
+ });
11
+ };
@@ -0,0 +1,237 @@
1
+ import { HttpRequest } from './HttpRequest';
2
+ import { Router } from './Router';
3
+ //import { Container } from '../Container/Container';
4
+ import type { HttpMethod } from './types/HttpMethod';
5
+ import { join, resolve } from 'node:path';
6
+ import ejs from 'ejs';
7
+ import type { RouteHandler } from './types/RouteHandler';
8
+
9
+ type ControllerClass = new () => Record<string, unknown>;
10
+ type ControllerResolver = (name: string) => Promise<ControllerClass>;
11
+
12
+ interface KernelOptions {
13
+ controllerResolver?: ControllerResolver;
14
+ viewsRoot?: string;
15
+ }
16
+
17
+ type ViewPayload = [
18
+ template: string,
19
+ data?: Record<string, any>,
20
+ layout?: string | null,
21
+ footerPartial?: string,
22
+ titlePartial?: string
23
+ ];
24
+
25
+ const isViewPayload = (value: any): value is ViewPayload => {
26
+ if (!Array.isArray(value)) return false;
27
+ if (typeof value[0] !== "string") return false;
28
+ if (value[1] !== undefined && (typeof value[1] !== "object" || value[1] === null)) return false;
29
+ if (value[2] !== undefined && typeof value[2] !== "string" && value[2] !== null) return false;
30
+ if (value[3] !== undefined && typeof value[3] !== "string") return false;
31
+ if (value[4] !== undefined && typeof value[4] !== "string") return false;
32
+ return true;
33
+ };
34
+
35
+ const safeViewPath = (view: string) => view.replace(/\.\./g, '').replace(/\/+/g, '/');
36
+
37
+ export class Kernel {
38
+ constructor(
39
+ private router = new Router(),
40
+ //private container = new Container(),
41
+ private middleware: Array<Function> = [],
42
+ private options: KernelOptions = {}
43
+ ) {}
44
+
45
+ private async resolveController(name: string): Promise<ControllerClass> {
46
+ if (!this.options.controllerResolver) {
47
+ throw new Error(`No controller resolver configured for handler ${name}`);
48
+ }
49
+
50
+ return this.options.controllerResolver(name);
51
+ }
52
+
53
+ private viewRoot(): string {
54
+ return this.options.viewsRoot ?? join(resolve(), "resources", "views");
55
+ }
56
+
57
+ /**
58
+ * Main entrypoint for every HTTP request.
59
+ */
60
+ async handle(request: Request): Promise<Response> {
61
+ const url = new URL(request.url);
62
+ const httpRequest = new HttpRequest({
63
+ method: request.method,
64
+ url: url,
65
+ headers: request.headers,
66
+ query: url.searchParams,
67
+ body: await request.json().catch(() => null)
68
+ });
69
+
70
+ try {
71
+ // Run middleware pipeline
72
+ const response = await this.runMiddlewarePipeline(httpRequest, async () => {
73
+ return this.dispatchToRouter(httpRequest)
74
+ });
75
+
76
+ return await this.normalizeResponse(response);
77
+ } catch (error) {
78
+ console.error(error);
79
+ return this.handleException("Middleware couldn't process the request", 500);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Run middleware in sequence
85
+ */
86
+ private async runMiddlewarePipeline(req: HttpRequest, finalHandler: Function) {
87
+ let index = -1;
88
+
89
+ const runner = async (i: number): Promise<Response> => {
90
+ if (i <= index) throw new Error("next() called multiple times");
91
+ index = i;
92
+
93
+ const middleware = this.middleware[i];
94
+ if (!middleware) return finalHandler(req);
95
+
96
+ return middleware(req, () => runner(i + 1));
97
+ }
98
+
99
+ return runner(0);
100
+ }
101
+
102
+ /**
103
+ * Ask the router to resolve the route and call the controller
104
+ */
105
+ private async dispatchToRouter(req: HttpRequest) {
106
+
107
+ const route = this.router.match(req.method as HttpMethod, req.path());
108
+
109
+ if (!route) return new Response("Not Found", { status: 404 });
110
+
111
+ req.setParams(route.params);
112
+
113
+ // Allows functional routes
114
+ if (typeof route.handler === "function") {
115
+ return route.handler(req);
116
+ }
117
+
118
+ const handler = route.handler as RouteHandler;
119
+
120
+ if (typeof handler !== "string") {
121
+ return new Response("Invalid route handler type", { status: 500 });
122
+ }
123
+
124
+ const parts: string[] = handler.split("@");
125
+
126
+ if (parts.length !== 2) {
127
+ return new Response("Invalid route handler format", { status: 500 });
128
+ }
129
+
130
+ const [controllerName, method] = parts;
131
+
132
+ const ImportedControllerClass = await this.resolveController(controllerName!);
133
+
134
+ const controllerInstance = new ImportedControllerClass();
135
+
136
+ const action = (controllerInstance as any)[method!] as (req: HttpRequest) => Promise<Response> | Response;
137
+
138
+ if (typeof action !== "function") return new Response("Action not found", { status: 500 });
139
+
140
+ const result = await action.call(controllerInstance, req);
141
+
142
+ return result;
143
+ }
144
+
145
+ /**
146
+ * Convert controller output into a proper Response
147
+ */
148
+ private async normalizeResponse(result: any): Promise<Response> {
149
+ if (result instanceof Response) return result;
150
+
151
+ if (isViewPayload(result)) {
152
+ const [
153
+ template,
154
+ data = {},
155
+ layout = "base/layouts/main",
156
+ footerPartial = "base/partials/footer",
157
+ titlePartial = "base/partials/title",
158
+ ] = result;
159
+
160
+ const safeTemplate = safeViewPath(template);
161
+
162
+ // Inject env appName into titleData if available
163
+ process.env.appName ? data.titleData = `${data.titleData} | ${process.env.appName}`: null;
164
+
165
+ try {
166
+ const childPath = join(this.viewRoot(), `${safeTemplate}.ejs`);
167
+ const bodyHtml = await ejs.renderFile(childPath, data || {});
168
+
169
+ // Wrap in layout if provided
170
+ if (layout) {
171
+ const returnPath = async (path: string): Promise<string> =>
172
+ join(this.viewRoot(), `${safeViewPath(path)}.ejs`);
173
+
174
+ const [layoutPath, footerPath, titlePath] = await Promise.all([
175
+ returnPath(layout),
176
+ returnPath(footerPartial),
177
+ returnPath(titlePartial)
178
+ ]);
179
+
180
+ console.info(`Rendering layout: ${layoutPath}`);
181
+
182
+ const [bodyHtml, footerHtml, titleHtml] = await Promise.all([
183
+ ejs.renderFile(childPath, data || {}),
184
+ ejs.renderFile(footerPath, {
185
+ footerData:(data as any).footerData
186
+ || ''
187
+ }),
188
+ ejs.renderFile(titlePath, {
189
+ titleData: (data as any).titleData
190
+ || ''
191
+ })
192
+ ]);
193
+
194
+ console.info(`Rendering view: ${childPath}`);
195
+
196
+ const finalHtml = await ejs.renderFile(layoutPath, {
197
+ ...(data ?? {}),
198
+ body: bodyHtml,
199
+ footer: footerHtml,
200
+ title: titleHtml
201
+ });
202
+
203
+ return new Response(finalHtml, {
204
+ headers: { "Content-Type": "text/html; charset=utf-8" }
205
+ });
206
+ }
207
+
208
+ // No layout, return child as-is
209
+ return new Response(bodyHtml, {
210
+ headers: { "Content-Type": "text/html; charset=utf-8" }
211
+ });
212
+ } catch (err) {
213
+ console.error(err);
214
+ return this.handleException("Error rendering value", 500)
215
+ }
216
+ }
217
+
218
+ if (typeof result === "object" && result !== null) {
219
+ return new Response(JSON.stringify(result), {
220
+ headers: { "Content-Type": "application/json" }
221
+ });
222
+ }
223
+
224
+ return new Response(String(result ?? ""), { status: 200 });
225
+ }
226
+
227
+ /**
228
+ * Centralized error handling
229
+ */
230
+ private handleException(error: any, code: number): Response {
231
+ console.error(error);
232
+ const errorString = error ?? "Internal Server Error";
233
+ return new Response(errorString, {
234
+ status: code
235
+ });
236
+ }
237
+ }
@@ -0,0 +1,63 @@
1
+ import type { RouteDefinitionInterface } from './interfaces/RouteDefinitionInterface';
2
+ import type { RouteMatchInterface } from './interfaces/RouteMatchInterface';
3
+ import type { HttpMethod } from './types/HttpMethod';
4
+ import type { RouteHandler } from './types/RouteHandler';
5
+
6
+ export class Router {
7
+ private routes: RouteDefinitionInterface[] = [];
8
+
9
+ get(path: string, handler: RouteHandler) {
10
+ this.add("GET", path, handler);
11
+ }
12
+
13
+ post(path: string, handler: RouteHandler) {
14
+ this.add("POST", path, handler);
15
+ }
16
+
17
+ put(path: string, handler: RouteHandler) {
18
+ this.add("PUT", path, handler);
19
+ }
20
+
21
+ delete(path: string, handler: RouteHandler) {
22
+ this.add("DELETE", path, handler);
23
+ }
24
+
25
+ match(method: HttpMethod, requestPath: string): RouteMatchInterface | null {
26
+ for (const route of this.routes) {
27
+ if (route.method !== method) continue;
28
+
29
+ const params = this.matchPath(route.path, requestPath);
30
+ if (params) {
31
+ return { handler: route.handler, params };
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ add(method: HttpMethod, path: string, handler: RouteHandler): void {
38
+ this.routes.push({ method, path, handler });
39
+ }
40
+
41
+ private matchPath(routePath: string, requestPath: string): Record<string, string> | null {
42
+ const routeParts: string[] = routePath.split('/').filter(Boolean);
43
+ const reqParts: string[] = requestPath.split('/').filter(Boolean);
44
+
45
+ if (routeParts.length !== reqParts.length) return null;
46
+
47
+ const params: Record<string, string> = {};
48
+
49
+ if (routeParts.length === reqParts.length) {
50
+ for (let i = 0; i < routeParts.length; i++) {
51
+ const r: string = routeParts[i]!;
52
+ const q: string = reqParts[i]!;
53
+
54
+ if (r?.startsWith(":")) {
55
+ params[r.slice(1)] = q;
56
+ } else if (r !== q) {
57
+ return null;
58
+ }
59
+ }
60
+ }
61
+ return params;
62
+ }
63
+ }
@@ -0,0 +1,23 @@
1
+ import { HttpRequest } from "../HttpRequest";
2
+
3
+ export async function fromWebRequest(req: Request): Promise<HttpRequest> {
4
+ const url = new URL (req.url);
5
+
6
+ let body: unknown = null;
7
+ const contentType = req.headers.get("content-type") || "";
8
+
9
+ if (contentType.includes("application/json")) {
10
+ body = await req.json().catch(() => null);
11
+ } else if (contentType.includes("application/x-www-form-urlencoded")) {
12
+ const text = await req.text();
13
+ body = Object.fromEntries(new URLSearchParams(text));
14
+ }
15
+
16
+ return new HttpRequest({
17
+ method: req.method,
18
+ url: url,
19
+ headers: req.headers,
20
+ query: url.searchParams,
21
+ body,
22
+ });
23
+ }
@@ -0,0 +1,7 @@
1
+ export interface HttpRequestProps {
2
+ method: string;
3
+ url: URL;
4
+ headers: Headers;
5
+ query: URLSearchParams;
6
+ body: unknown;
7
+ }
@@ -0,0 +1,10 @@
1
+ import type { HttpMethod } from '../types/HttpMethod';
2
+ import type { RouteHandler } from '../types/RouteHandler';
3
+
4
+
5
+
6
+ export interface RouteDefinitionInterface {
7
+ method: HttpMethod;
8
+ path: string;
9
+ handler: RouteHandler;
10
+ }
@@ -0,0 +1,6 @@
1
+ import type { RouteHandler } from "../types/RouteHandler";
2
+
3
+ export interface RouteMatchInterface {
4
+ handler: RouteHandler;
5
+ params: Record<string, string>;
6
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Determines if the given pathname matches any of the configured static paths.
3
+ * @param pathname
4
+ * @param staticPaths
5
+ * @returns boolean
6
+ */
7
+ export const isStaticPath =(pathname: string, staticPaths: string[]): boolean =>
8
+ staticPaths.some((staticPath) => {
9
+ // Directory-style static paths keep prefix matching (e.g. /assets/...).
10
+ if (staticPath.endsWith("/")) {
11
+ return pathname.startsWith(staticPath);
12
+ }
13
+
14
+ // File-style static paths must match exactly (e.g. /favicon.png).
15
+ return pathname === staticPath;
16
+ });
17
+
18
+ /**
19
+ * Defines the file system path for a given URL pathname, ensuring it maps to the public directory.
20
+ * @param pathname
21
+ * @param join
22
+ * @returns string
23
+ */
24
+ export const toPublicFilePath = (pathname: string, path: {
25
+ resolve: (...parts: string[]) => string;
26
+ normalize: (p: string) => string;
27
+ sep: string;
28
+ }, publicRootInput?: string): string => {
29
+ const { resolve, normalize, sep } = path;
30
+ const publicRoot = publicRootInput ? resolve(publicRootInput) : resolve(process.cwd(), "public");
31
+
32
+ const cleaned = normalize(
33
+ pathname.replace(/^\/(static|assets|public)\//, "")
34
+ );
35
+
36
+ const resolved = resolve(publicRoot, "." + cleaned);
37
+ const root = publicRoot.endsWith(sep) ? publicRoot : publicRoot + sep;
38
+
39
+ if (resolved !== publicRoot && !resolved.startsWith(root)) {
40
+ throw new Error("Invalid static file path");
41
+ }
42
+ return resolved;
43
+ }
44
+
45
+ /**
46
+ * Determines the content type for a given file path based on its extension.
47
+ * @param pathname
48
+ * @param exts
49
+ * @param extname
50
+ * @returns string
51
+ */
52
+ export const contentTypeFor = (pathname: string, exts: Record<string, string>, extname: Function): string => {
53
+ const ext = extname(pathname).split('.').pop()?.toLowerCase() || '';
54
+
55
+ if (exts[ext]) {
56
+ console.info(`Matched content type: ${exts[ext]}`);
57
+ return exts[ext];
58
+ }
59
+
60
+ return "application/octet-stream";
61
+ }
@@ -0,0 +1 @@
1
+ export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
@@ -0,0 +1,3 @@
1
+ import type { HttpRequest } from '../HttpRequest';
2
+
3
+ export type RouteHandler = | string | ((request: HttpRequest) => Response | Promise<Response>);
@@ -0,0 +1,9 @@
1
+ export default class View {
2
+ static render(template: string, data: Record<string, any>): Promise<Response> {
3
+ return new Promise<Response>((resolve) => {
4
+ resolve(new Response(JSON.stringify({ template, data }), {
5
+ headers: { "Content-Type": "application/json" }
6
+ }));
7
+ });
8
+ }
9
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./Http/Kernel";
2
+ export * from "./Http/Router";
3
+ export * from "./Http/HttpRequest";
4
+ export * from "./Http/HttpResponse";
5
+ export * from "./Http/staticFiles";