@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 +15 -0
- package/src/Container/Container.ts +0 -0
- package/src/Http/HttpRequest.ts +56 -0
- package/src/Http/HttpResponse.ts +11 -0
- package/src/Http/Kernel.ts +237 -0
- package/src/Http/Router.ts +63 -0
- package/src/Http/factories/fromWebRequest.ts +23 -0
- package/src/Http/interfaces/HttpRequestPropsInterface.ts +7 -0
- package/src/Http/interfaces/RouteDefinitionInterface.ts +10 -0
- package/src/Http/interfaces/RouteMatchInterface.ts +6 -0
- package/src/Http/staticFiles.ts +61 -0
- package/src/Http/types/HttpMethod.ts +1 -0
- package/src/Http/types/RouteHandler.ts +3 -0
- package/src/View/View.ts +9 -0
- package/src/index.ts +5 -0
package/package.json
ADDED
|
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,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,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";
|
package/src/View/View.ts
ADDED
|
@@ -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
|
+
}
|