@lithia-js/core 1.0.0-canary.1 → 1.0.0-canary.3
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/CHANGELOG.md +18 -0
- package/package.json +3 -3
- package/.turbo/turbo-build.log +0 -4
- package/src/config.ts +0 -212
- package/src/context/event-context.ts +0 -66
- package/src/context/index.ts +0 -32
- package/src/context/lithia-context.ts +0 -59
- package/src/context/route-context.ts +0 -89
- package/src/env.ts +0 -31
- package/src/errors.ts +0 -96
- package/src/hooks/dependency-hooks.ts +0 -122
- package/src/hooks/event-hooks.ts +0 -69
- package/src/hooks/index.ts +0 -58
- package/src/hooks/route-hooks.ts +0 -177
- package/src/lib.ts +0 -27
- package/src/lithia.ts +0 -777
- package/src/logger.ts +0 -66
- package/src/module-loader.ts +0 -45
- package/src/server/event-processor.ts +0 -344
- package/src/server/http-server.ts +0 -371
- package/src/server/middlewares/validation.ts +0 -46
- package/src/server/request-processor.ts +0 -860
- package/src/server/request.ts +0 -247
- package/src/server/response.ts +0 -204
- package/tsconfig.build.tsbuildinfo +0 -1
package/src/server/request.ts
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import type { IncomingHttpHeaders, IncomingMessage } from "node:http";
|
|
2
|
-
import busboy, { type FileInfo } from "busboy";
|
|
3
|
-
import { type Cookies, parse as parseCookie } from "cookie";
|
|
4
|
-
import type { Lithia } from "../lithia";
|
|
5
|
-
|
|
6
|
-
/** Route parameters extracted from the route matcher. */
|
|
7
|
-
export type Params = Record<string, any>;
|
|
8
|
-
|
|
9
|
-
/** Parsed query parameters. Values may be a string or an array for repeated keys. */
|
|
10
|
-
export type Query = Record<string, any>;
|
|
11
|
-
|
|
12
|
-
/** Represents an uploaded file. */
|
|
13
|
-
export interface UploadedFile extends FileInfo {
|
|
14
|
-
/** Field name in the form. */
|
|
15
|
-
fieldname: string;
|
|
16
|
-
/** File buffer. */
|
|
17
|
-
buffer: Buffer;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Request wrapper passed to route handlers.
|
|
21
|
-
*
|
|
22
|
-
* Provides convenient accessors for headers, params, query, body and helpers
|
|
23
|
-
* such as `ip()` and `isSecure()`. The wrapper also exposes a simple
|
|
24
|
-
* per-request storage via `get()`/`set()` and cookie parsing helpers.
|
|
25
|
-
*/
|
|
26
|
-
export class LithiaRequest {
|
|
27
|
-
headers: Readonly<IncomingHttpHeaders>;
|
|
28
|
-
method: Readonly<string>;
|
|
29
|
-
params: Params;
|
|
30
|
-
pathname: Readonly<string>;
|
|
31
|
-
query: Query;
|
|
32
|
-
|
|
33
|
-
private storage = new Map<string, unknown>();
|
|
34
|
-
private _bodyCache: unknown | null = null;
|
|
35
|
-
private _filesCache: UploadedFile[] | null = null;
|
|
36
|
-
private _cookies: Cookies | null = null;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Wrap a Node `IncomingMessage` into a `LithiaRequest`.
|
|
40
|
-
*
|
|
41
|
-
* `lithia` is the runtime instance and is stored in request-local
|
|
42
|
-
* storage under the `lithia` key for handlers that need access.
|
|
43
|
-
*/
|
|
44
|
-
constructor(
|
|
45
|
-
private readonly req: IncomingMessage,
|
|
46
|
-
private readonly lithia: Lithia,
|
|
47
|
-
) {
|
|
48
|
-
const protocol =
|
|
49
|
-
(req.headers["x-forwarded-proto"] as string) === "https" ||
|
|
50
|
-
(req.socket as any)?.encrypted === true
|
|
51
|
-
? "https"
|
|
52
|
-
: "http";
|
|
53
|
-
const host = req.headers.host || "unknown";
|
|
54
|
-
const fullUrl = `${protocol}://${host}${req.url || "/"}`;
|
|
55
|
-
const url = new URL(fullUrl);
|
|
56
|
-
|
|
57
|
-
this.pathname = url.pathname;
|
|
58
|
-
this.method = (req.method || "GET").toUpperCase();
|
|
59
|
-
this.headers = req.headers;
|
|
60
|
-
this.query = parseQueryToObject(url.searchParams);
|
|
61
|
-
this.params = {};
|
|
62
|
-
|
|
63
|
-
this.storage.set("lithia", this.lithia);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Read and parse the request body. For JSON content-type this returns the
|
|
68
|
-
* parsed object; for other content types it returns the raw string. The
|
|
69
|
-
* result is cached and subsequent calls return the cached value.
|
|
70
|
-
*/
|
|
71
|
-
async body<T>(): Promise<T> {
|
|
72
|
-
if (!["POST", "PUT", "PATCH", "DELETE"].includes(this.method)) {
|
|
73
|
-
return {} as T;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (this._bodyCache !== null) return this._bodyCache as T;
|
|
77
|
-
|
|
78
|
-
const contentType = (this.headers["content-type"] || "") as string;
|
|
79
|
-
|
|
80
|
-
if (contentType.includes("multipart/form-data")) {
|
|
81
|
-
await this.parseMultipart();
|
|
82
|
-
return this._bodyCache as T;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const contentLength = parseInt(
|
|
86
|
-
(this.headers["content-length"] as string) || "0",
|
|
87
|
-
10,
|
|
88
|
-
);
|
|
89
|
-
const maxBodySize = this.lithia.options.http.maxBodySize || 1024 * 1024;
|
|
90
|
-
|
|
91
|
-
if (contentLength > maxBodySize) {
|
|
92
|
-
throw new Error("Request body too large");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const body = await new Promise<T>((resolve, reject) => {
|
|
96
|
-
let bodyData = "";
|
|
97
|
-
this.req.on("data", (chunk) => {
|
|
98
|
-
bodyData += chunk;
|
|
99
|
-
if (bodyData.length > maxBodySize) {
|
|
100
|
-
reject(new Error("Request body too large"));
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
this.req.on("end", () => {
|
|
105
|
-
if (!bodyData) return resolve({} as T);
|
|
106
|
-
try {
|
|
107
|
-
if (contentType.includes("application/json")) {
|
|
108
|
-
resolve(JSON.parse(bodyData) as T);
|
|
109
|
-
} else {
|
|
110
|
-
resolve(bodyData as unknown as T);
|
|
111
|
-
}
|
|
112
|
-
} catch (err) {
|
|
113
|
-
reject(err);
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
this.req.on("error", (err) => reject(err));
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
this._bodyCache = body;
|
|
121
|
-
this.storage.set("body", body);
|
|
122
|
-
return body;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Get uploaded files from a multipart/form-data request.
|
|
127
|
-
*/
|
|
128
|
-
async files(): Promise<UploadedFile[]> {
|
|
129
|
-
const contentType = (this.headers["content-type"] || "") as string;
|
|
130
|
-
if (!contentType.includes("multipart/form-data")) return [];
|
|
131
|
-
|
|
132
|
-
if (this._filesCache !== null) return this._filesCache;
|
|
133
|
-
|
|
134
|
-
await this.parseMultipart();
|
|
135
|
-
return this._filesCache!;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Set the request body manually (e.g. after validation/sanitization). */
|
|
139
|
-
setBody(value: unknown) {
|
|
140
|
-
this._bodyCache = value;
|
|
141
|
-
this.storage.set("body", value);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private async parseMultipart(): Promise<void> {
|
|
145
|
-
if (this._bodyCache !== null && this._filesCache !== null) return;
|
|
146
|
-
|
|
147
|
-
return new Promise((resolve, reject) => {
|
|
148
|
-
const bb = busboy({ headers: this.headers });
|
|
149
|
-
const fields: Record<string, any> = {};
|
|
150
|
-
const files: UploadedFile[] = [];
|
|
151
|
-
|
|
152
|
-
bb.on("file", (name, file, info) => {
|
|
153
|
-
const chunks: Buffer[] = [];
|
|
154
|
-
file.on("data", (data) => chunks.push(data));
|
|
155
|
-
file.on("end", () => {
|
|
156
|
-
files.push({
|
|
157
|
-
fieldname: name,
|
|
158
|
-
buffer: Buffer.concat(chunks),
|
|
159
|
-
...info,
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
bb.on("field", (name, val) => {
|
|
165
|
-
fields[name] = val;
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
bb.on("close", () => {
|
|
169
|
-
this._bodyCache = fields;
|
|
170
|
-
this._filesCache = files;
|
|
171
|
-
resolve();
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
bb.on("error", (err) => reject(err));
|
|
175
|
-
|
|
176
|
-
this.req.pipe(bb);
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Retrieve a value from per-request storage. */
|
|
181
|
-
get<T>(key: string): T | undefined {
|
|
182
|
-
return this.storage.get(key) as T | undefined;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Store a value in per-request storage. */
|
|
186
|
-
set(key: string, value: unknown): void {
|
|
187
|
-
this.storage.set(key, value);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Parse cookies from the `Cookie` header. */
|
|
191
|
-
cookies(): Cookies {
|
|
192
|
-
if (this._cookies === null) {
|
|
193
|
-
const cookieHeader = this.headers.cookie;
|
|
194
|
-
const parsedCookies = cookieHeader ? parseCookie(cookieHeader) : {};
|
|
195
|
-
this._cookies = parsedCookies;
|
|
196
|
-
}
|
|
197
|
-
return this._cookies || {};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Return a single cookie value by name. */
|
|
201
|
-
cookie(name: string): string | undefined {
|
|
202
|
-
return this.cookies()[name];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** Client IP address (considers `X-Forwarded-For` and `X-Real-IP`). */
|
|
206
|
-
ip(): string {
|
|
207
|
-
return (
|
|
208
|
-
(this.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ||
|
|
209
|
-
(this.headers["x-real-ip"] as string) ||
|
|
210
|
-
(this.req.socket as any)?.remoteAddress ||
|
|
211
|
-
"unknown"
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** `User-Agent` header value or empty string. */
|
|
216
|
-
userAgent(): string {
|
|
217
|
-
return (this.headers["user-agent"] as string) || "";
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Return true when the request was made over TLS. */
|
|
221
|
-
isSecure(): boolean {
|
|
222
|
-
return (
|
|
223
|
-
(this.headers["x-forwarded-proto"] as string) === "https" ||
|
|
224
|
-
(this.req.socket as any)?.encrypted === true
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** Host header or `unknown` when missing. */
|
|
229
|
-
host(): string {
|
|
230
|
-
return (this.headers.host as string) || "unknown";
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** Full URL constructed from host and pathname. */
|
|
234
|
-
url(): string {
|
|
235
|
-
return `${this.isSecure() ? "https" : "http"}://${this.host()}${this.pathname}`;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export function parseQueryToObject(raw: URLSearchParams) {
|
|
240
|
-
const obj: Query = {};
|
|
241
|
-
for (const [k, v] of raw.entries()) {
|
|
242
|
-
if (obj[k] === undefined) obj[k] = v;
|
|
243
|
-
else if (Array.isArray(obj[k])) (obj[k] as string[]).push(v);
|
|
244
|
-
else obj[k] = [obj[k] as string, v];
|
|
245
|
-
}
|
|
246
|
-
return obj;
|
|
247
|
-
}
|
package/src/server/response.ts
DELETED
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
import { createReadStream, statSync } from "node:fs";
|
|
2
|
-
import type { OutgoingHttpHeaders, ServerResponse } from "node:http";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { serialize as serializeCookie } from "cookie";
|
|
5
|
-
import { logger } from "../logger";
|
|
6
|
-
|
|
7
|
-
/** Options used when setting cookies on the response. */
|
|
8
|
-
export interface CookieOptions {
|
|
9
|
-
domain?: string;
|
|
10
|
-
expires?: Date;
|
|
11
|
-
httpOnly?: boolean;
|
|
12
|
-
maxAge?: number;
|
|
13
|
-
path?: string;
|
|
14
|
-
sameSite?: boolean | "lax" | "strict" | "none";
|
|
15
|
-
secure?: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* High-level response wrapper used by Lithia handlers.
|
|
20
|
-
*
|
|
21
|
-
* This class wraps Node's `ServerResponse` and provides convenient helper
|
|
22
|
-
* methods for common response patterns used by Lithia handlers:
|
|
23
|
-
* - sending JSON (`json`), text/primitive bodies (`send`) and redirects
|
|
24
|
-
* - streaming static files (`sendFile`)
|
|
25
|
-
* - managing headers and queued cookies (`addHeader`, `setHeaders`, `cookie`)
|
|
26
|
-
*
|
|
27
|
-
* It also tracks whether the response has already been sent and will throw
|
|
28
|
-
* if you attempt to mutate the response after it was finalized.
|
|
29
|
-
*
|
|
30
|
-
* Instances are created per incoming request and are intended to be passed
|
|
31
|
-
* through middleware and route handlers.
|
|
32
|
-
*/
|
|
33
|
-
export class LithiaResponse {
|
|
34
|
-
_ended = false;
|
|
35
|
-
private _cookies: Array<{
|
|
36
|
-
name: string;
|
|
37
|
-
value: string;
|
|
38
|
-
options?: CookieOptions;
|
|
39
|
-
}> = [];
|
|
40
|
-
|
|
41
|
-
/** Create a new `LithiaResponse` wrapping a Node `ServerResponse`. */
|
|
42
|
-
constructor(private res: ServerResponse) {
|
|
43
|
-
this.on = this.res.on.bind(this.res);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Current HTTP status code for the response. */
|
|
47
|
-
get statusCode(): number {
|
|
48
|
-
return this.res.statusCode;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Convenience passthrough to the underlying `ServerResponse#on`. */
|
|
52
|
-
on: (event: string, listener: (chunk: unknown) => void) => void;
|
|
53
|
-
|
|
54
|
-
private checkIfEnded(): void {
|
|
55
|
-
if (this._ended)
|
|
56
|
-
throw new Error("Cannot modify response after it was sent");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Set the numeric HTTP status code. */
|
|
60
|
-
status(status: number): LithiaResponse {
|
|
61
|
-
this.checkIfEnded();
|
|
62
|
-
if (status < 100 || status > 599)
|
|
63
|
-
throw new Error("Invalid HTTP status code");
|
|
64
|
-
this.res.statusCode = status;
|
|
65
|
-
return this;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Return a copy of currently set headers. */
|
|
69
|
-
headers(): Readonly<OutgoingHttpHeaders> {
|
|
70
|
-
return this.res.getHeaders();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Replace multiple headers at once. */
|
|
74
|
-
setHeaders(headers: OutgoingHttpHeaders): LithiaResponse {
|
|
75
|
-
this.checkIfEnded();
|
|
76
|
-
|
|
77
|
-
Object.entries(headers).forEach(([k, v]) => {
|
|
78
|
-
this.res.setHeader(k, v as any);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
return this;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Set a single header. */
|
|
85
|
-
addHeader(name: string, value: string | number | string[]): LithiaResponse {
|
|
86
|
-
this.checkIfEnded();
|
|
87
|
-
this.res.setHeader(name, value as any);
|
|
88
|
-
return this;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Remove a header if present. */
|
|
92
|
-
removeHeader(name: string): LithiaResponse {
|
|
93
|
-
this.checkIfEnded();
|
|
94
|
-
this.res.removeHeader(name);
|
|
95
|
-
return this;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** End the response without a body. */
|
|
99
|
-
end(): void {
|
|
100
|
-
this.checkIfEnded();
|
|
101
|
-
this.res.end();
|
|
102
|
-
this._ended = true;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Send an object as JSON. Sets `Content-Type: application/json`. */
|
|
106
|
-
json(obj: object): void {
|
|
107
|
-
this.checkIfEnded();
|
|
108
|
-
try {
|
|
109
|
-
const body = JSON.stringify(obj);
|
|
110
|
-
this.res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
111
|
-
this.res.end(body);
|
|
112
|
-
} catch (err) {
|
|
113
|
-
logger.error("Failed to serialize JSON response:", err);
|
|
114
|
-
this.res.statusCode = 500;
|
|
115
|
-
this.res.end("Internal Server Error");
|
|
116
|
-
} finally {
|
|
117
|
-
this._ended = true;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Send an HTTP redirect to `url`. */
|
|
122
|
-
redirect(url: string, status = 302): void {
|
|
123
|
-
this.checkIfEnded();
|
|
124
|
-
this.status(status).addHeader("Location", url).end();
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Send a response body.
|
|
129
|
-
* - `Buffer` bodies are sent as `application/octet-stream`.
|
|
130
|
-
* - Objects are serialized as JSON.
|
|
131
|
-
* - Primitives are sent as text/plain.
|
|
132
|
-
*/
|
|
133
|
-
send(data?: unknown): void {
|
|
134
|
-
// Set cookies first
|
|
135
|
-
if (this._cookies.length > 0) {
|
|
136
|
-
const existing = (this.res.getHeader("Set-Cookie") as string[]) || [];
|
|
137
|
-
const serialized = this._cookies.map((c) =>
|
|
138
|
-
serializeCookie(c.name, c.value, c.options as any),
|
|
139
|
-
);
|
|
140
|
-
this.res.setHeader("Set-Cookie", [...existing, ...serialized]);
|
|
141
|
-
this._cookies = [];
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
this.checkIfEnded();
|
|
145
|
-
try {
|
|
146
|
-
if (data === undefined || data === null) {
|
|
147
|
-
this.end();
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (Buffer.isBuffer(data)) {
|
|
152
|
-
if (!this.res.getHeader("Content-Type"))
|
|
153
|
-
this.addHeader("Content-Type", "application/octet-stream");
|
|
154
|
-
this.res.end(data);
|
|
155
|
-
} else if (typeof data === "object") {
|
|
156
|
-
this.json(data as object);
|
|
157
|
-
} else {
|
|
158
|
-
if (!this.res.getHeader("Content-Type"))
|
|
159
|
-
this.addHeader("Content-Type", "text/plain; charset=utf-8");
|
|
160
|
-
this.res.end(String(data));
|
|
161
|
-
}
|
|
162
|
-
} finally {
|
|
163
|
-
this._ended = true;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Queue a cookie to be set on the response. */
|
|
168
|
-
cookie(
|
|
169
|
-
name: string,
|
|
170
|
-
value: string,
|
|
171
|
-
options: CookieOptions = {},
|
|
172
|
-
): LithiaResponse {
|
|
173
|
-
this.checkIfEnded();
|
|
174
|
-
this._cookies.push({ name, value, options });
|
|
175
|
-
return this;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Clear a cookie by setting it with an expired date. */
|
|
179
|
-
clearCookie(name: string, options: CookieOptions = {}): LithiaResponse {
|
|
180
|
-
this.checkIfEnded();
|
|
181
|
-
return this.cookie(name, "", { ...options, expires: new Date(0) });
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Send a static file from disk. `opts.root` may be used to resolve relative paths. */
|
|
185
|
-
sendFile(filePath: string, opts: { root?: string } = {}): void {
|
|
186
|
-
this.checkIfEnded();
|
|
187
|
-
try {
|
|
188
|
-
const full = opts.root ? join(opts.root, filePath) : filePath;
|
|
189
|
-
const stats = statSync(full);
|
|
190
|
-
if (!stats.isFile()) throw new Error("Not a file");
|
|
191
|
-
|
|
192
|
-
this.addHeader("Content-Length", String(stats.size));
|
|
193
|
-
const stream = createReadStream(full);
|
|
194
|
-
stream.pipe(this.res);
|
|
195
|
-
stream.on("error", () =>
|
|
196
|
-
this.status(404).send({ error: "File not found" }),
|
|
197
|
-
);
|
|
198
|
-
} catch {
|
|
199
|
-
this.status(404).send({ error: "File not found" });
|
|
200
|
-
} finally {
|
|
201
|
-
this._ended = true;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|