@lithia-js/core 1.0.0-canary.2 → 1.0.0-canary.21
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/README.md +26 -43
- package/dist/_index.d.ts +245 -0
- package/dist/_index.mjs +2106 -0
- package/dist/_index.mjs.map +1 -0
- package/dist/index.d.ts +824 -0
- package/dist/index.mjs +856 -0
- package/dist/index.mjs.map +1 -0
- package/dist/protocol-DBwVPJYN.d.ts +332 -0
- package/dist/tasks-X-3clDS8.d.ts +31 -0
- package/dist/workers/app-worker.d.ts +2 -0
- package/dist/workers/app-worker.mjs +1907 -0
- package/dist/workers/app-worker.mjs.map +1 -0
- package/dist/workers/task-worker.d.ts +45 -0
- package/dist/workers/task-worker.mjs +146 -0
- package/dist/workers/task-worker.mjs.map +1 -0
- package/package.json +47 -23
- package/CHANGELOG.md +0 -31
- package/dist/config.d.ts +0 -101
- package/dist/config.js +0 -113
- package/dist/config.js.map +0 -1
- package/dist/context/event-context.d.ts +0 -53
- package/dist/context/event-context.js +0 -42
- package/dist/context/event-context.js.map +0 -1
- package/dist/context/index.d.ts +0 -16
- package/dist/context/index.js +0 -29
- package/dist/context/index.js.map +0 -1
- package/dist/context/lithia-context.d.ts +0 -47
- package/dist/context/lithia-context.js +0 -43
- package/dist/context/lithia-context.js.map +0 -1
- package/dist/context/route-context.d.ts +0 -74
- package/dist/context/route-context.js +0 -42
- package/dist/context/route-context.js.map +0 -1
- package/dist/env.d.ts +0 -1
- package/dist/env.js +0 -32
- package/dist/env.js.map +0 -1
- package/dist/errors.d.ts +0 -51
- package/dist/errors.js +0 -80
- package/dist/errors.js.map +0 -1
- package/dist/hooks/dependency-hooks.d.ts +0 -105
- package/dist/hooks/dependency-hooks.js +0 -96
- package/dist/hooks/dependency-hooks.js.map +0 -1
- package/dist/hooks/event-hooks.d.ts +0 -61
- package/dist/hooks/event-hooks.js +0 -70
- package/dist/hooks/event-hooks.js.map +0 -1
- package/dist/hooks/index.d.ts +0 -41
- package/dist/hooks/index.js +0 -59
- package/dist/hooks/index.js.map +0 -1
- package/dist/hooks/route-hooks.d.ts +0 -154
- package/dist/hooks/route-hooks.js +0 -174
- package/dist/hooks/route-hooks.js.map +0 -1
- package/dist/lib.d.ts +0 -10
- package/dist/lib.js +0 -30
- package/dist/lib.js.map +0 -1
- package/dist/lithia.d.ts +0 -447
- package/dist/lithia.js +0 -649
- package/dist/lithia.js.map +0 -1
- package/dist/logger.d.ts +0 -11
- package/dist/logger.js +0 -55
- package/dist/logger.js.map +0 -1
- package/dist/module-loader.d.ts +0 -12
- package/dist/module-loader.js +0 -78
- package/dist/module-loader.js.map +0 -1
- package/dist/server/event-processor.d.ts +0 -195
- package/dist/server/event-processor.js +0 -253
- package/dist/server/event-processor.js.map +0 -1
- package/dist/server/http-server.d.ts +0 -196
- package/dist/server/http-server.js +0 -295
- package/dist/server/http-server.js.map +0 -1
- package/dist/server/middlewares/validation.d.ts +0 -12
- package/dist/server/middlewares/validation.js +0 -34
- package/dist/server/middlewares/validation.js.map +0 -1
- package/dist/server/request-processor.d.ts +0 -400
- package/dist/server/request-processor.js +0 -652
- package/dist/server/request-processor.js.map +0 -1
- package/dist/server/request.d.ts +0 -73
- package/dist/server/request.js +0 -207
- package/dist/server/request.js.map +0 -1
- package/dist/server/response.d.ts +0 -69
- package/dist/server/response.js +0 -173
- package/dist/server/response.js.map +0 -1
- 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.json +0 -8
|
@@ -0,0 +1,1907 @@
|
|
|
1
|
+
import { parentPort, workerData, isMainThread } from 'worker_threads';
|
|
2
|
+
import { logger, red, yellow, green } from '@lithia-js/utils';
|
|
3
|
+
import { randomUUID, createHash } from 'crypto';
|
|
4
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
5
|
+
import { createServer as createServer$1 } from 'http';
|
|
6
|
+
import { createServer } from 'https';
|
|
7
|
+
import busboy from 'busboy';
|
|
8
|
+
import { parse, serialize } from 'cookie';
|
|
9
|
+
import { access, readFile, constants, stat } from 'fs/promises';
|
|
10
|
+
import { pathToFileURL } from 'url';
|
|
11
|
+
import { isAsyncFunction } from 'util/types';
|
|
12
|
+
import path, { join, normalize, extname } from 'path';
|
|
13
|
+
import { statSync, createReadStream } from 'fs';
|
|
14
|
+
import { Server } from 'socket.io';
|
|
15
|
+
import cron from 'node-cron';
|
|
16
|
+
|
|
17
|
+
// src/runtime/workers/app-worker.ts
|
|
18
|
+
|
|
19
|
+
// src/errors/base.ts
|
|
20
|
+
var LithiaError = class extends Error {
|
|
21
|
+
isLithiaError = true;
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = this.constructor.name;
|
|
25
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var LithiaClientError = class extends LithiaError {
|
|
29
|
+
constructor(message, statusCode, details) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.statusCode = statusCode;
|
|
32
|
+
this.details = details;
|
|
33
|
+
}
|
|
34
|
+
timestamp = /* @__PURE__ */ new Date();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// src/errors/internal/loader.ts
|
|
38
|
+
var NoDefaultExportError = class extends LithiaError {
|
|
39
|
+
constructor(filePath) {
|
|
40
|
+
super(`Module at '${filePath}' is missing a default export.`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var NoAsyncDefaultExportError = class extends LithiaError {
|
|
44
|
+
constructor(filePath) {
|
|
45
|
+
super(`Default export at '${filePath}' must be an async function.`);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// src/context/lithia-context.ts
|
|
50
|
+
var LITHIA_CONTEXT_KEY = /* @__PURE__ */ Symbol.for("lithia.base_context.v1");
|
|
51
|
+
function getGlobalLithiaStore() {
|
|
52
|
+
const globalAny = globalThis;
|
|
53
|
+
if (!globalAny[LITHIA_CONTEXT_KEY]) {
|
|
54
|
+
globalAny[LITHIA_CONTEXT_KEY] = new AsyncLocalStorage();
|
|
55
|
+
}
|
|
56
|
+
return globalAny[LITHIA_CONTEXT_KEY];
|
|
57
|
+
}
|
|
58
|
+
var lithiaContextStore = getGlobalLithiaStore();
|
|
59
|
+
function runInLithiaContext(context, fn) {
|
|
60
|
+
return lithiaContextStore.run(context, fn);
|
|
61
|
+
}
|
|
62
|
+
var ROUTE_CONTEXT_KEY = /* @__PURE__ */ Symbol.for("lithia.route_context.v1");
|
|
63
|
+
function getGlobalRouteStore() {
|
|
64
|
+
const globalAny = globalThis;
|
|
65
|
+
if (!globalAny[ROUTE_CONTEXT_KEY]) {
|
|
66
|
+
globalAny[ROUTE_CONTEXT_KEY] = new AsyncLocalStorage();
|
|
67
|
+
}
|
|
68
|
+
return globalAny[ROUTE_CONTEXT_KEY];
|
|
69
|
+
}
|
|
70
|
+
var routeContextStore = getGlobalRouteStore();
|
|
71
|
+
|
|
72
|
+
// src/errors/app/client.ts
|
|
73
|
+
var BadRequestError = class extends LithiaClientError {
|
|
74
|
+
constructor(m, d) {
|
|
75
|
+
super(m, 400, d);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var RouteNotFoundError = class extends LithiaClientError {
|
|
79
|
+
constructor(m, d) {
|
|
80
|
+
super(m, 404, d);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// src/errors/app/server.ts
|
|
85
|
+
var InternalServerError = class extends LithiaClientError {
|
|
86
|
+
constructor(m, d) {
|
|
87
|
+
super(m, 500, d);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// src/transport/http/request.ts
|
|
92
|
+
var LithiaRequest = class {
|
|
93
|
+
/**
|
|
94
|
+
* Creates a request wrapper for the current HTTP transaction.
|
|
95
|
+
*
|
|
96
|
+
* The constructor captures headers, reconstructs a best-effort absolute URL,
|
|
97
|
+
* normalizes the HTTP method to uppercase, and initializes parsed query and
|
|
98
|
+
* route-param containers for later middleware and handler use.
|
|
99
|
+
*
|
|
100
|
+
* @param {IncomingMessage} req - Raw Node.js request object received by the
|
|
101
|
+
* HTTP server.
|
|
102
|
+
* @param {{ maxBodySize?: number }} opts - Per-request parsing options used
|
|
103
|
+
* when consuming the request body stream.
|
|
104
|
+
*/
|
|
105
|
+
constructor(req, opts) {
|
|
106
|
+
this.req = req;
|
|
107
|
+
this.opts = opts;
|
|
108
|
+
this.headers = req.headers;
|
|
109
|
+
const isSecure = this.isSecure();
|
|
110
|
+
const host = this.headers.host || req.headers.host || "unknown";
|
|
111
|
+
const fullUrl = `${isSecure ? "https" : "http"}://${host}${req.url || "/"}`;
|
|
112
|
+
const url = new URL(fullUrl);
|
|
113
|
+
this.pathname = url.pathname;
|
|
114
|
+
this.method = (req.method || "GET").toUpperCase();
|
|
115
|
+
this.query = parseQueryToObject(url.searchParams);
|
|
116
|
+
this.params = {};
|
|
117
|
+
}
|
|
118
|
+
headers;
|
|
119
|
+
method;
|
|
120
|
+
pathname;
|
|
121
|
+
query;
|
|
122
|
+
params;
|
|
123
|
+
storage = /* @__PURE__ */ new Map();
|
|
124
|
+
_bodyCache = null;
|
|
125
|
+
_filesCache = null;
|
|
126
|
+
_cookies = null;
|
|
127
|
+
/**
|
|
128
|
+
* Returns the best-effort client IP address for the current request.
|
|
129
|
+
*
|
|
130
|
+
* The lookup prefers proxy-forwarded headers before falling back to the raw
|
|
131
|
+
* socket address, which makes the result suitable for deployments behind
|
|
132
|
+
* reverse proxies that preserve `x-forwarded-for` or `x-real-ip`.
|
|
133
|
+
*
|
|
134
|
+
* @returns {string} The resolved client IP address, or `"unknown"` when no
|
|
135
|
+
* address can be derived.
|
|
136
|
+
*/
|
|
137
|
+
ip() {
|
|
138
|
+
return this.headers["x-forwarded-for"]?.split(",")[0]?.trim() || this.headers["x-real-ip"] || this.req.socket?.remoteAddress || "unknown";
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Returns the current request user-agent string.
|
|
142
|
+
*
|
|
143
|
+
* @returns {string} The raw `user-agent` header value, or an empty string
|
|
144
|
+
* when the header is missing.
|
|
145
|
+
*/
|
|
146
|
+
userAgent() {
|
|
147
|
+
return this.headers["user-agent"] || "";
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Returns whether the current request is using HTTPS.
|
|
151
|
+
*
|
|
152
|
+
* The check prefers `x-forwarded-proto` for proxy-aware deployments and then
|
|
153
|
+
* falls back to the encrypted state of the underlying socket.
|
|
154
|
+
*
|
|
155
|
+
* @returns {boolean} `true` when the request should be treated as HTTPS.
|
|
156
|
+
*/
|
|
157
|
+
isSecure() {
|
|
158
|
+
return this.headers["x-forwarded-proto"] === "https" || this.req.socket?.encrypted === true;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Returns the request host header.
|
|
162
|
+
*
|
|
163
|
+
* @returns {string} The current host header value, or `"unknown"` when it is
|
|
164
|
+
* not available.
|
|
165
|
+
*/
|
|
166
|
+
host() {
|
|
167
|
+
return this.headers.host || "unknown";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Returns the absolute request URL reconstructed from the current request.
|
|
171
|
+
*
|
|
172
|
+
* This helper rebuilds the URL from the current security state, host header,
|
|
173
|
+
* and parsed pathname. It does not append the original query string.
|
|
174
|
+
*
|
|
175
|
+
* @returns {string} Absolute URL for the current request pathname.
|
|
176
|
+
*/
|
|
177
|
+
url() {
|
|
178
|
+
return `${this.isSecure() ? "https" : "http"}://${this.host()}${this.pathname}`;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Parses and returns the request body.
|
|
182
|
+
*
|
|
183
|
+
* JSON and plain text bodies are supported automatically. Multipart requests
|
|
184
|
+
* populate both `body()` and `files()` through a shared parsing pass. The
|
|
185
|
+
* parsed value is cached after the first read so later consumers do not touch
|
|
186
|
+
* the underlying stream again.
|
|
187
|
+
*
|
|
188
|
+
* Requests whose method is not one of `POST`, `PUT`, `PATCH`, or `DELETE`
|
|
189
|
+
* resolve to an empty object without reading the stream.
|
|
190
|
+
*
|
|
191
|
+
* @returns {Promise<T>} Parsed request body, multipart field map, raw text, or
|
|
192
|
+
* an empty object for methods that do not consume a body by default.
|
|
193
|
+
* @throws {BadRequestError} Thrown when the declared or streamed body size
|
|
194
|
+
* exceeds `maxBodySize`, or when JSON parsing fails.
|
|
195
|
+
*/
|
|
196
|
+
async body() {
|
|
197
|
+
const methodsWithBody = ["POST", "PUT", "PATCH", "DELETE"];
|
|
198
|
+
if (!methodsWithBody.includes(this.method)) {
|
|
199
|
+
return {};
|
|
200
|
+
}
|
|
201
|
+
if (this._bodyCache !== null) return this._bodyCache;
|
|
202
|
+
const contentType = this.headers["content-type"] || "";
|
|
203
|
+
if (contentType.includes("multipart/form-data")) {
|
|
204
|
+
await this.parseMultipart();
|
|
205
|
+
return this._bodyCache;
|
|
206
|
+
}
|
|
207
|
+
const contentLength = parseInt(
|
|
208
|
+
this.headers["content-length"] || "0",
|
|
209
|
+
10
|
|
210
|
+
);
|
|
211
|
+
const maxBodySize = this.opts.maxBodySize || 1024 * 1024;
|
|
212
|
+
if (contentLength > maxBodySize) {
|
|
213
|
+
throw new BadRequestError("Request body too large.");
|
|
214
|
+
}
|
|
215
|
+
const body = await new Promise((resolve, reject) => {
|
|
216
|
+
const chunks = [];
|
|
217
|
+
let currentSize = 0;
|
|
218
|
+
this.req.on("data", (chunk) => {
|
|
219
|
+
currentSize += chunk.length;
|
|
220
|
+
if (currentSize > maxBodySize) {
|
|
221
|
+
reject(new BadRequestError("Request body too large."));
|
|
222
|
+
}
|
|
223
|
+
chunks.push(chunk);
|
|
224
|
+
});
|
|
225
|
+
this.req.on("end", () => {
|
|
226
|
+
if (chunks.length === 0) return resolve({});
|
|
227
|
+
const rawBody = Buffer.concat(chunks).toString("utf-8");
|
|
228
|
+
try {
|
|
229
|
+
if (contentType.includes("application/json")) {
|
|
230
|
+
resolve(JSON.parse(rawBody));
|
|
231
|
+
} else {
|
|
232
|
+
resolve(rawBody);
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
reject(new BadRequestError("Invalid request body format."));
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
this.req.on("error", (err) => reject(err));
|
|
239
|
+
});
|
|
240
|
+
this._bodyCache = body;
|
|
241
|
+
this.storage.set("body", body);
|
|
242
|
+
return body;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Returns uploaded files for multipart/form-data requests.
|
|
246
|
+
*
|
|
247
|
+
* `files()` shares the same multipart parsing pass used by `body()`. The
|
|
248
|
+
* first call buffers every uploaded file into memory and caches both the
|
|
249
|
+
* parsed field object and file array for later access.
|
|
250
|
+
*
|
|
251
|
+
* @returns {Promise<UploadedFile[]>} Buffered multipart files, or an empty
|
|
252
|
+
* array when the request is not multipart.
|
|
253
|
+
*/
|
|
254
|
+
async files() {
|
|
255
|
+
const contentType = this.headers["content-type"] || "";
|
|
256
|
+
if (!contentType.includes("multipart/form-data")) return [];
|
|
257
|
+
if (this._filesCache !== null) return this._filesCache;
|
|
258
|
+
await this.parseMultipart();
|
|
259
|
+
return this._filesCache || [];
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Overrides the cached body value for the current request context.
|
|
263
|
+
*
|
|
264
|
+
* This mutates only the wrapper cache and the internal storage map. It does
|
|
265
|
+
* not modify the underlying Node.js request stream.
|
|
266
|
+
*
|
|
267
|
+
* @param {unknown} value - Replacement body value to expose through `body()`
|
|
268
|
+
* and internal request storage.
|
|
269
|
+
*/
|
|
270
|
+
setBody(value) {
|
|
271
|
+
this._bodyCache = value;
|
|
272
|
+
this.storage.set("body", value);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Returns all parsed cookies from the request.
|
|
276
|
+
*
|
|
277
|
+
* Cookies are parsed lazily on first access and cached for the remainder of
|
|
278
|
+
* the request lifecycle.
|
|
279
|
+
*
|
|
280
|
+
* @returns {Cookies} Parsed cookie map for the current request.
|
|
281
|
+
*/
|
|
282
|
+
cookies() {
|
|
283
|
+
if (this._cookies === null) {
|
|
284
|
+
const cookieHeader = this.headers.cookie;
|
|
285
|
+
this._cookies = cookieHeader ? parse(cookieHeader) : {};
|
|
286
|
+
}
|
|
287
|
+
return this._cookies;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Returns a single cookie value by name.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} name - Cookie name to read from the parsed cookie map.
|
|
293
|
+
* @returns {string | undefined} The cookie value when present.
|
|
294
|
+
*/
|
|
295
|
+
cookie(name) {
|
|
296
|
+
return this.cookies()[name];
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Returns a value stored in the per-request internal storage map.
|
|
300
|
+
*
|
|
301
|
+
* This storage is local to the current request wrapper and can be used by
|
|
302
|
+
* middleware and handlers to exchange derived values without mutating the
|
|
303
|
+
* typed request surface.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} key - Storage key associated with the requested value.
|
|
306
|
+
* @returns {T | undefined} Stored value for the key, if one exists.
|
|
307
|
+
*/
|
|
308
|
+
get(key) {
|
|
309
|
+
return this.storage.get(key);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Stores a value in the per-request internal storage map.
|
|
313
|
+
*
|
|
314
|
+
* @param {string} key - Storage key to create or overwrite.
|
|
315
|
+
* @param {unknown} value - Arbitrary value to retain for the lifetime of the
|
|
316
|
+
* current request wrapper.
|
|
317
|
+
*/
|
|
318
|
+
set(key, value) {
|
|
319
|
+
this.storage.set(key, value);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Parses a multipart/form-data request into cached fields and file buffers.
|
|
323
|
+
*
|
|
324
|
+
* The request stream is piped into Busboy exactly once. Field values are
|
|
325
|
+
* collected into a plain object, file contents are buffered fully in memory,
|
|
326
|
+
* and both results are stored in the request cache and internal storage map.
|
|
327
|
+
*
|
|
328
|
+
* @returns {Promise<void>} Resolves after Busboy finishes consuming the
|
|
329
|
+
* multipart stream and caches the parsed payload.
|
|
330
|
+
*/
|
|
331
|
+
async parseMultipart() {
|
|
332
|
+
if (this._bodyCache !== null && this._filesCache !== null) return;
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
const bb = busboy({ headers: this.headers });
|
|
335
|
+
const fields = {};
|
|
336
|
+
const files = [];
|
|
337
|
+
bb.on("file", (name, file, info) => {
|
|
338
|
+
const chunks = [];
|
|
339
|
+
file.on("data", (data) => chunks.push(data));
|
|
340
|
+
file.on("end", () => {
|
|
341
|
+
files.push({
|
|
342
|
+
fieldname: name,
|
|
343
|
+
buffer: Buffer.concat(chunks),
|
|
344
|
+
...info
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
bb.on("field", (name, value) => {
|
|
349
|
+
fields[name] = value;
|
|
350
|
+
});
|
|
351
|
+
bb.on("finish", () => {
|
|
352
|
+
this._bodyCache = fields;
|
|
353
|
+
this._filesCache = files;
|
|
354
|
+
this.storage.set("body", fields);
|
|
355
|
+
this.storage.set("files", files);
|
|
356
|
+
resolve();
|
|
357
|
+
});
|
|
358
|
+
bb.on("error", reject);
|
|
359
|
+
this.req.pipe(bb);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
function parseQueryToObject(searchParams) {
|
|
364
|
+
const query = {};
|
|
365
|
+
for (const [key, value] of searchParams.entries()) {
|
|
366
|
+
query[key] = value;
|
|
367
|
+
}
|
|
368
|
+
return query;
|
|
369
|
+
}
|
|
370
|
+
async function loadModule(filePath) {
|
|
371
|
+
const exists = await access(filePath, constants.F_OK).then(() => true).catch(() => false);
|
|
372
|
+
if (!exists) {
|
|
373
|
+
throw new LithiaError(
|
|
374
|
+
`The module at '${filePath}' could not be found or is not accessible.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
let mod;
|
|
378
|
+
try {
|
|
379
|
+
mod = await import(pathToFileURL(filePath).href);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
throw new LithiaError(
|
|
382
|
+
`Failed to import module at '${filePath}': ${err.message}`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
if (!mod || !mod.default) {
|
|
386
|
+
throw new NoDefaultExportError(filePath);
|
|
387
|
+
}
|
|
388
|
+
const isFunction = typeof mod.default === "function";
|
|
389
|
+
const isAsync = isAsyncFunction(mod.default);
|
|
390
|
+
if (!isFunction || !isAsync) {
|
|
391
|
+
throw new NoAsyncDefaultExportError(filePath);
|
|
392
|
+
}
|
|
393
|
+
return mod;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// src/shared/pipeline.ts
|
|
397
|
+
async function executePipeline(steps, handler) {
|
|
398
|
+
let index = -1;
|
|
399
|
+
const dispatch = async (stepIndex) => {
|
|
400
|
+
if (stepIndex <= index) return;
|
|
401
|
+
index = stepIndex;
|
|
402
|
+
if (stepIndex === steps.length) {
|
|
403
|
+
return handler();
|
|
404
|
+
}
|
|
405
|
+
const step = steps[stepIndex];
|
|
406
|
+
if (step) {
|
|
407
|
+
await step(() => dispatch(stepIndex + 1));
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
await dispatch(0);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/transport/http/cors-policy.ts
|
|
414
|
+
function applyCorsPolicy(config, req, res) {
|
|
415
|
+
const { cors } = config.http;
|
|
416
|
+
if (!cors?.origin?.length) return false;
|
|
417
|
+
const requestOrigin = req.headers.origin;
|
|
418
|
+
if (!requestOrigin) return false;
|
|
419
|
+
const isOriginAllowed = cors.origin.includes("*") || cors.origin.some((allowed) => allowed === requestOrigin);
|
|
420
|
+
if (!isOriginAllowed) return false;
|
|
421
|
+
const allowedOrigin = cors.origin.includes("*") ? cors.credentials ? requestOrigin : "*" : requestOrigin;
|
|
422
|
+
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
|
|
423
|
+
res.setHeader("Vary", "Origin");
|
|
424
|
+
if (cors.credentials) {
|
|
425
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
426
|
+
}
|
|
427
|
+
if (cors.exposedHeaders?.length) {
|
|
428
|
+
res.setHeader(
|
|
429
|
+
"Access-Control-Expose-Headers",
|
|
430
|
+
cors.exposedHeaders.join(", ")
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (req.method !== "OPTIONS") return false;
|
|
434
|
+
if (cors.methods?.length) {
|
|
435
|
+
res.setHeader("Access-Control-Allow-Methods", cors.methods.join(", "));
|
|
436
|
+
}
|
|
437
|
+
if (cors.allowedHeaders?.length) {
|
|
438
|
+
res.setHeader(
|
|
439
|
+
"Access-Control-Allow-Headers",
|
|
440
|
+
cors.allowedHeaders.join(", ")
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (cors.maxAge != null) {
|
|
444
|
+
res.setHeader("Access-Control-Max-Age", String(cors.maxAge));
|
|
445
|
+
}
|
|
446
|
+
res.status(204).end();
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
var DOCS_FILE = path.join("_lithia", "scalar.html");
|
|
450
|
+
var SPEC_FILE = path.join("_lithia", "openapi.json");
|
|
451
|
+
async function serveOpenAPIAsset(config, req, res) {
|
|
452
|
+
if (!config.openapi?.enabled) return false;
|
|
453
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
454
|
+
const docsPath = normalizeOpenAPIPath(config.openapi.docsPath || "/docs");
|
|
455
|
+
const specPath = normalizeOpenAPIPath(
|
|
456
|
+
config.openapi.specPath || "/openapi.json"
|
|
457
|
+
);
|
|
458
|
+
const pathname = normalizeOpenAPIPath(req.pathname);
|
|
459
|
+
if (pathname === docsPath) {
|
|
460
|
+
const html = await readOpenAPIArtifact(config.outDir, DOCS_FILE);
|
|
461
|
+
if (!html) return false;
|
|
462
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
463
|
+
res.send(req.method === "HEAD" ? void 0 : html);
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
if (pathname === specPath) {
|
|
467
|
+
const spec = await readOpenAPIArtifact(config.outDir, SPEC_FILE);
|
|
468
|
+
if (!spec) return false;
|
|
469
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
470
|
+
res.send(req.method === "HEAD" ? void 0 : spec);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
function normalizeOpenAPIPath(input) {
|
|
476
|
+
if (!input) return "/";
|
|
477
|
+
const normalized = input.startsWith("/") ? input : `/${input}`;
|
|
478
|
+
if (normalized.length > 1 && normalized.endsWith("/")) {
|
|
479
|
+
return normalized.slice(0, -1);
|
|
480
|
+
}
|
|
481
|
+
return normalized;
|
|
482
|
+
}
|
|
483
|
+
async function readOpenAPIArtifact(outDir, relativePath) {
|
|
484
|
+
try {
|
|
485
|
+
return await readFile(
|
|
486
|
+
path.join(process.cwd(), outDir, relativePath),
|
|
487
|
+
"utf-8"
|
|
488
|
+
);
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function produceDigest(err) {
|
|
494
|
+
const errString = err instanceof Error ? err.stack || err.message : String(err);
|
|
495
|
+
const hash = createHash("sha256").update(`${errString}${Date.now()}${Math.random()}`).digest("hex");
|
|
496
|
+
return hash.slice(0, 12);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/transport/http/request-error-handler.ts
|
|
500
|
+
function handleRequestError(environment, req, res, err) {
|
|
501
|
+
if (res._ended) return;
|
|
502
|
+
const error = err instanceof LithiaClientError ? err : new InternalServerError(err);
|
|
503
|
+
const isProd = environment === "production";
|
|
504
|
+
const statusCode = error.statusCode || 500;
|
|
505
|
+
const digest = produceDigest(err);
|
|
506
|
+
const message = isProd && statusCode >= 500 ? "Internal Server Error" : error.message;
|
|
507
|
+
res.status(statusCode).json({
|
|
508
|
+
error: {
|
|
509
|
+
statusCode,
|
|
510
|
+
message,
|
|
511
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
512
|
+
digest,
|
|
513
|
+
path: req.pathname,
|
|
514
|
+
method: req.method,
|
|
515
|
+
details: error.details
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
if (statusCode >= 500) {
|
|
519
|
+
logger.error(`Digest: ${red(digest)}`);
|
|
520
|
+
logger.info(`Path: ${req.method} ${req.pathname}`);
|
|
521
|
+
logger.info(err.stack || err);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/transport/http/route-matcher.ts
|
|
526
|
+
var RouteMatcher = class {
|
|
527
|
+
regexCache = /* @__PURE__ */ new Map();
|
|
528
|
+
/**
|
|
529
|
+
* Finds the first discovered route that matches the current request.
|
|
530
|
+
*
|
|
531
|
+
* Method-specific routes only match the corresponding request method, while
|
|
532
|
+
* method-agnostic routes can match any method. Path matching is delegated to
|
|
533
|
+
* the route manifest regex generated during discovery.
|
|
534
|
+
*
|
|
535
|
+
* @param {LithiaRequest} req - Current request wrapper.
|
|
536
|
+
* @param {Route[]} routes - Discovered route manifests to scan in order.
|
|
537
|
+
* @returns {Route | undefined} The first matching route manifest, if one
|
|
538
|
+
* exists.
|
|
539
|
+
*/
|
|
540
|
+
findRoute(req, routes) {
|
|
541
|
+
const method = req.method.toLowerCase();
|
|
542
|
+
return routes.find((route) => {
|
|
543
|
+
const methodMatches = !route.method || route.method.toLowerCase() === method;
|
|
544
|
+
if (!methodMatches) return false;
|
|
545
|
+
const regex = this.getOrCreateRegex(route.regex);
|
|
546
|
+
return regex.test(req.pathname);
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Synchronizes route-specific context after a route match is selected.
|
|
551
|
+
*
|
|
552
|
+
* When a request context store is active, the matched route manifest is
|
|
553
|
+
* attached to it. Dynamic routes also populate `req.params` by extracting
|
|
554
|
+
* capture groups from the request pathname.
|
|
555
|
+
*
|
|
556
|
+
* @param {Route} route - Matched route manifest.
|
|
557
|
+
* @param {LithiaRequest} req - Current request wrapper whose params may be
|
|
558
|
+
* updated.
|
|
559
|
+
*/
|
|
560
|
+
setupContext(route, req) {
|
|
561
|
+
const store = routeContextStore.getStore();
|
|
562
|
+
if (store) {
|
|
563
|
+
store.route = route;
|
|
564
|
+
}
|
|
565
|
+
if (route.dynamic) {
|
|
566
|
+
req.params = this.extractParams(req.pathname, route);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Extracts decoded dynamic params from a matched pathname.
|
|
571
|
+
*
|
|
572
|
+
* Param names are derived from `route.path` segments such as `:id`, while
|
|
573
|
+
* values are read from the corresponding regex capture groups in the actual
|
|
574
|
+
* request pathname.
|
|
575
|
+
*
|
|
576
|
+
* @param {string} pathname - Incoming request pathname.
|
|
577
|
+
* @param {Route} route - Matched route manifest that supplies the paramized
|
|
578
|
+
* path and regex pattern.
|
|
579
|
+
* @returns {Params} Decoded param object for the current request.
|
|
580
|
+
*/
|
|
581
|
+
extractParams(pathname, route) {
|
|
582
|
+
const regex = this.getOrCreateRegex(route.regex);
|
|
583
|
+
const match = pathname.match(regex);
|
|
584
|
+
if (!match) return {};
|
|
585
|
+
const paramNames = (route.path.match(/:([^/]+)/g) || []).map(
|
|
586
|
+
(segment) => segment.slice(1)
|
|
587
|
+
);
|
|
588
|
+
return paramNames.reduce((params, name, index) => {
|
|
589
|
+
const value = match[index + 1];
|
|
590
|
+
params[name] = value ? decodeURIComponent(value) : value;
|
|
591
|
+
return params;
|
|
592
|
+
}, {});
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Returns a cached regular expression for a route manifest pattern.
|
|
596
|
+
*
|
|
597
|
+
* @param {string} pattern - Serialized regex pattern generated during route
|
|
598
|
+
* discovery.
|
|
599
|
+
* @returns {RegExp} Cached or newly compiled regular expression.
|
|
600
|
+
*/
|
|
601
|
+
getOrCreateRegex(pattern) {
|
|
602
|
+
let regex = this.regexCache.get(pattern);
|
|
603
|
+
if (!regex) {
|
|
604
|
+
regex = new RegExp(pattern);
|
|
605
|
+
this.regexCache.set(pattern, regex);
|
|
606
|
+
}
|
|
607
|
+
return regex;
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
async function serveStaticAsset(config, req, res) {
|
|
611
|
+
const { static: staticConfig, http } = config;
|
|
612
|
+
if (!staticConfig?.root || req.method !== "GET" && req.method !== "HEAD") {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
let relativePath = req.pathname;
|
|
616
|
+
if (staticConfig.prefix) {
|
|
617
|
+
if (!relativePath.startsWith(staticConfig.prefix)) return false;
|
|
618
|
+
relativePath = relativePath.slice(staticConfig.prefix.length);
|
|
619
|
+
}
|
|
620
|
+
const safePath = normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
621
|
+
const fullPath = join(staticConfig.root, safePath);
|
|
622
|
+
try {
|
|
623
|
+
const stats = await stat(fullPath);
|
|
624
|
+
if (!stats.isFile()) return false;
|
|
625
|
+
const ext = extname(fullPath).toLowerCase();
|
|
626
|
+
const mime = http.mimeTypes?.[ext];
|
|
627
|
+
if (!mime) return false;
|
|
628
|
+
res.setHeader("Content-Type", mime);
|
|
629
|
+
res.send(fullPath);
|
|
630
|
+
return true;
|
|
631
|
+
} catch {
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// src/transport/http/request-pipeline.ts
|
|
637
|
+
var LithiaRequestProcessor = class {
|
|
638
|
+
/**
|
|
639
|
+
* Creates an HTTP request processor bound to a running Lithia app instance.
|
|
640
|
+
*
|
|
641
|
+
* @param {LithiaApp} app - Runtime app state that provides config, manifests,
|
|
642
|
+
* middleware registries, and environment information for request execution.
|
|
643
|
+
*/
|
|
644
|
+
constructor(app) {
|
|
645
|
+
this.app = app;
|
|
646
|
+
}
|
|
647
|
+
matcher = new RouteMatcher();
|
|
648
|
+
/**
|
|
649
|
+
* Processes a single HTTP request through the Lithia route pipeline.
|
|
650
|
+
*
|
|
651
|
+
* The method may terminate early when CORS preflight handling, OpenAPI asset
|
|
652
|
+
* serving, or static asset serving fully handles the request. Otherwise it
|
|
653
|
+
* resolves the matching route, prepares request params through the route
|
|
654
|
+
* matcher, loads the route module, executes global and route-local middleware,
|
|
655
|
+
* and then runs the route handler.
|
|
656
|
+
*
|
|
657
|
+
* If the handler and middleware chain complete without ending the response,
|
|
658
|
+
* the processor closes the response automatically with `res.end()`.
|
|
659
|
+
*
|
|
660
|
+
* @param {LithiaRequest} req - Current request wrapper.
|
|
661
|
+
* @param {LithiaResponse} res - Current response wrapper.
|
|
662
|
+
* @returns {Promise<void>} Resolves after the request reaches a terminal
|
|
663
|
+
* response or the error handler completes.
|
|
664
|
+
*/
|
|
665
|
+
async process(req, res) {
|
|
666
|
+
const startTime = performance.now();
|
|
667
|
+
try {
|
|
668
|
+
this.setInitialHeaders(res);
|
|
669
|
+
if (applyCorsPolicy(this.app.config, req, res)) return;
|
|
670
|
+
if (await serveOpenAPIAsset(this.app.config, req, res)) return;
|
|
671
|
+
if (await serveStaticAsset(this.app.config, req, res)) return;
|
|
672
|
+
const route = this.matcher.findRoute(req, this.app.routes);
|
|
673
|
+
if (!route) {
|
|
674
|
+
throw new RouteNotFoundError(
|
|
675
|
+
`The requested resource '${req.pathname}' does not exist on this server.`
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
this.matcher.setupContext(route, req);
|
|
679
|
+
const mod = await loadModule(route.filePath);
|
|
680
|
+
const pipeline = [
|
|
681
|
+
...this.app.globalRouteMiddlewares,
|
|
682
|
+
...mod.middlewares || []
|
|
683
|
+
];
|
|
684
|
+
await this.runRoutePipeline(pipeline, req, res, async () => {
|
|
685
|
+
await mod.default(req, res);
|
|
686
|
+
});
|
|
687
|
+
if (!res._ended) res.end();
|
|
688
|
+
} catch (error) {
|
|
689
|
+
handleRequestError(this.app.environment, req, res, error);
|
|
690
|
+
} finally {
|
|
691
|
+
this.logRequest(req, res, performance.now() - startTime);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Executes the composed route middleware chain and then the final handler.
|
|
696
|
+
*
|
|
697
|
+
* Middleware functions are adapted to the generic shared pipeline runner,
|
|
698
|
+
* which guarantees in-order execution as long as each middleware calls its
|
|
699
|
+
* `next()` continuation.
|
|
700
|
+
*
|
|
701
|
+
* @param {RouteMiddleware[]} middlewares - Global and route-scoped middleware
|
|
702
|
+
* stack to execute before the handler.
|
|
703
|
+
* @param {LithiaRequest} req - Current request wrapper shared across the
|
|
704
|
+
* entire pipeline.
|
|
705
|
+
* @param {LithiaResponse} res - Current response wrapper shared across the
|
|
706
|
+
* entire pipeline.
|
|
707
|
+
* @param {RouteHandler} handler - Final route handler invoked after all
|
|
708
|
+
* middleware completes.
|
|
709
|
+
* @returns {Promise<void>} Resolves after the middleware chain and handler
|
|
710
|
+
* finish.
|
|
711
|
+
*/
|
|
712
|
+
async runRoutePipeline(middlewares, req, res, handler) {
|
|
713
|
+
await executePipeline(
|
|
714
|
+
middlewares.map(
|
|
715
|
+
(middleware) => (next) => middleware(req, res, () => next())
|
|
716
|
+
),
|
|
717
|
+
() => handler(req, res)
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Applies framework-default headers before any route logic runs.
|
|
722
|
+
*
|
|
723
|
+
* @param {LithiaResponse} res - Response wrapper mutated with initial
|
|
724
|
+
* framework headers.
|
|
725
|
+
*/
|
|
726
|
+
setInitialHeaders(res) {
|
|
727
|
+
res.setHeader("X-Powered-By", "Lithia");
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Logs the completed HTTP request when request logging is enabled.
|
|
731
|
+
*
|
|
732
|
+
* The status code is color-coded according to its category and the log is
|
|
733
|
+
* emitted only after the request has reached a terminal state.
|
|
734
|
+
*
|
|
735
|
+
* @param {LithiaRequest} req - Request wrapper used for method and pathname
|
|
736
|
+
* fields.
|
|
737
|
+
* @param {LithiaResponse} res - Response wrapper used for the final status
|
|
738
|
+
* code.
|
|
739
|
+
* @param {number} elapsed - Total request processing time in milliseconds.
|
|
740
|
+
*/
|
|
741
|
+
logRequest(req, res, elapsed) {
|
|
742
|
+
if (!this.app.config.logging.requests) return;
|
|
743
|
+
const status = res.statusCode;
|
|
744
|
+
const colorFunc = status >= 500 ? red : status >= 400 ? yellow : green;
|
|
745
|
+
logger.info(
|
|
746
|
+
`${req.method} ${req.pathname} ${colorFunc(status.toString())} - ${elapsed.toFixed(2)}ms`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
var LithiaResponse = class {
|
|
751
|
+
/**
|
|
752
|
+
* Creates a response wrapper for the current HTTP transaction.
|
|
753
|
+
*
|
|
754
|
+
* The wrapper binds a pass-through `on()` helper to the underlying Node.js
|
|
755
|
+
* response object so route-adjacent code can subscribe to response events
|
|
756
|
+
* without holding the raw `ServerResponse`.
|
|
757
|
+
*
|
|
758
|
+
* @param {ServerResponse} res - Raw Node.js response object associated with
|
|
759
|
+
* the current request.
|
|
760
|
+
*/
|
|
761
|
+
constructor(res) {
|
|
762
|
+
this.res = res;
|
|
763
|
+
this.on = this.res.on.bind(this.res);
|
|
764
|
+
}
|
|
765
|
+
_ended = false;
|
|
766
|
+
_cookies = [];
|
|
767
|
+
on;
|
|
768
|
+
/**
|
|
769
|
+
* Returns the current HTTP status code.
|
|
770
|
+
*
|
|
771
|
+
* @returns {number} Status code currently assigned to the underlying
|
|
772
|
+
* response.
|
|
773
|
+
*/
|
|
774
|
+
get statusCode() {
|
|
775
|
+
return this.res.statusCode;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Sets the HTTP status code for the response.
|
|
779
|
+
*
|
|
780
|
+
* This mutates the underlying response only while it is still active.
|
|
781
|
+
*
|
|
782
|
+
* @param {number} status - HTTP status code to assign before the response is
|
|
783
|
+
* sent.
|
|
784
|
+
* @returns {this} The current response wrapper for fluent chaining.
|
|
785
|
+
* @throws {Error} Thrown when the response has already ended or when the
|
|
786
|
+
* supplied status code falls outside the valid HTTP range.
|
|
787
|
+
*/
|
|
788
|
+
status(status) {
|
|
789
|
+
this.ensureActive();
|
|
790
|
+
if (status < 100 || status > 599) {
|
|
791
|
+
throw new Error(`Invalid HTTP status code: ${status}`);
|
|
792
|
+
}
|
|
793
|
+
this.res.statusCode = status;
|
|
794
|
+
return this;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Returns the currently assigned response headers.
|
|
798
|
+
*
|
|
799
|
+
* @returns {Readonly<OutgoingHttpHeaders>} Snapshot of the headers currently
|
|
800
|
+
* stored on the underlying response.
|
|
801
|
+
*/
|
|
802
|
+
headers() {
|
|
803
|
+
return this.res.getHeaders();
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Sets multiple response headers at once.
|
|
807
|
+
*
|
|
808
|
+
* @param {OutgoingHttpHeaders} headers - Header entries to assign to the
|
|
809
|
+
* response before it is sent.
|
|
810
|
+
* @returns {this} The current response wrapper for fluent chaining.
|
|
811
|
+
* @throws {Error} Thrown when the response has already ended.
|
|
812
|
+
*/
|
|
813
|
+
setHeaders(headers) {
|
|
814
|
+
this.ensureActive();
|
|
815
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
816
|
+
this.res.setHeader(key, value);
|
|
817
|
+
});
|
|
818
|
+
return this;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Sets a single response header.
|
|
822
|
+
*
|
|
823
|
+
* @param {string} name - Header name to create or overwrite.
|
|
824
|
+
* @param {string | number | string[]} value - Header value written to the
|
|
825
|
+
* underlying response.
|
|
826
|
+
* @returns {this} The current response wrapper for fluent chaining.
|
|
827
|
+
* @throws {Error} Thrown when the response has already ended.
|
|
828
|
+
*/
|
|
829
|
+
setHeader(name, value) {
|
|
830
|
+
this.ensureActive();
|
|
831
|
+
this.res.setHeader(name, value);
|
|
832
|
+
return this;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Removes a response header.
|
|
836
|
+
*
|
|
837
|
+
* @param {string} name - Header name to remove.
|
|
838
|
+
* @returns {this} The current response wrapper for fluent chaining.
|
|
839
|
+
* @throws {Error} Thrown when the response has already ended.
|
|
840
|
+
*/
|
|
841
|
+
removeHeader(name) {
|
|
842
|
+
this.ensureActive();
|
|
843
|
+
this.res.removeHeader(name);
|
|
844
|
+
return this;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Queues a cookie to be written when the response is sent.
|
|
848
|
+
*
|
|
849
|
+
* Cookies are accumulated in memory and serialized only when a terminal
|
|
850
|
+
* response method flushes headers.
|
|
851
|
+
*
|
|
852
|
+
* @param {string} name - Cookie name.
|
|
853
|
+
* @param {string} value - Cookie value.
|
|
854
|
+
* @param {CookieOptions} [options={}] - Cookie serialization options.
|
|
855
|
+
* @returns {this} The current response wrapper for fluent chaining.
|
|
856
|
+
* @throws {Error} Thrown when the response has already ended.
|
|
857
|
+
*/
|
|
858
|
+
cookie(name, value, options = {}) {
|
|
859
|
+
this.ensureActive();
|
|
860
|
+
this._cookies.push({ name, value, options });
|
|
861
|
+
return this;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Clears a cookie by expiring it immediately.
|
|
865
|
+
*
|
|
866
|
+
* @param {string} name - Cookie name to expire.
|
|
867
|
+
* @param {CookieOptions} [options={}] - Additional cookie attributes that
|
|
868
|
+
* must match the original cookie scope.
|
|
869
|
+
* @returns {this} The current response wrapper for fluent chaining.
|
|
870
|
+
*/
|
|
871
|
+
clearCookie(name, options = {}) {
|
|
872
|
+
return this.cookie(name, "", { ...options, expires: /* @__PURE__ */ new Date(0) });
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Sends a response body using a best-effort content type.
|
|
876
|
+
*
|
|
877
|
+
* The method flushes pending cookies before writing, chooses a default
|
|
878
|
+
* content type when none is set, and treats plain objects as JSON by
|
|
879
|
+
* delegating to `json()`. Calling `send()` is a terminal operation for the
|
|
880
|
+
* response lifecycle.
|
|
881
|
+
*
|
|
882
|
+
* @param {unknown} [data] - Response payload to send.
|
|
883
|
+
* @throws {Error} Thrown when the response has already ended.
|
|
884
|
+
*/
|
|
885
|
+
send(data) {
|
|
886
|
+
this.applyPendingCookies();
|
|
887
|
+
this.ensureActive();
|
|
888
|
+
try {
|
|
889
|
+
if (data === void 0 || data === null) {
|
|
890
|
+
this.end();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (Buffer.isBuffer(data)) {
|
|
894
|
+
if (!this.res.getHeader("Content-Type")) {
|
|
895
|
+
this.setHeader("Content-Type", "application/octet-stream");
|
|
896
|
+
}
|
|
897
|
+
this.res.end(data);
|
|
898
|
+
} else if (typeof data === "object") {
|
|
899
|
+
this.json(data);
|
|
900
|
+
return;
|
|
901
|
+
} else {
|
|
902
|
+
if (!this.res.getHeader("Content-Type")) {
|
|
903
|
+
this.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
904
|
+
}
|
|
905
|
+
this.res.end(String(data));
|
|
906
|
+
}
|
|
907
|
+
} finally {
|
|
908
|
+
this._ended = true;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Sends a JSON response.
|
|
913
|
+
*
|
|
914
|
+
* Pending cookies are flushed before serialization. If JSON serialization
|
|
915
|
+
* throws, the method logs the failure and falls back to a `500 Internal
|
|
916
|
+
* Server Error` response body.
|
|
917
|
+
*
|
|
918
|
+
* @param {object} obj - Plain object to serialize as JSON.
|
|
919
|
+
*/
|
|
920
|
+
json(obj) {
|
|
921
|
+
this.applyPendingCookies();
|
|
922
|
+
this.ensureActive();
|
|
923
|
+
try {
|
|
924
|
+
const body = JSON.stringify(obj);
|
|
925
|
+
this.res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
926
|
+
this.res.end(body);
|
|
927
|
+
} catch (err) {
|
|
928
|
+
logger.error("Failed to serialize JSON response:", err);
|
|
929
|
+
this.res.statusCode = 500;
|
|
930
|
+
this.res.end("Internal Server Error");
|
|
931
|
+
} finally {
|
|
932
|
+
this._ended = true;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Sends a redirect response.
|
|
937
|
+
*
|
|
938
|
+
* This sets the status code, writes the `Location` header, and then ends the
|
|
939
|
+
* response.
|
|
940
|
+
*
|
|
941
|
+
* @param {string} url - Redirect target written to the `Location` header.
|
|
942
|
+
* @param {number} [status=302] - Redirect status code.
|
|
943
|
+
*/
|
|
944
|
+
redirect(url, status = 302) {
|
|
945
|
+
this.status(status).setHeader("Location", url).end();
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Ends the response without sending additional data.
|
|
949
|
+
*
|
|
950
|
+
* Pending cookies are flushed before the underlying response is closed.
|
|
951
|
+
*
|
|
952
|
+
* @throws {Error} Thrown when the response has already ended.
|
|
953
|
+
*/
|
|
954
|
+
end() {
|
|
955
|
+
this.applyPendingCookies();
|
|
956
|
+
this.ensureActive();
|
|
957
|
+
this.res.end();
|
|
958
|
+
this._ended = true;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Streams a file to the client.
|
|
962
|
+
*
|
|
963
|
+
* The method resolves the final path, verifies that it points to a regular
|
|
964
|
+
* file, sets `Content-Length`, flushes pending cookies, and pipes the file
|
|
965
|
+
* stream into the underlying response. Missing files and stream failures fall
|
|
966
|
+
* back to a `404` JSON error payload.
|
|
967
|
+
*
|
|
968
|
+
* @param {string} filePath - File path to stream. When `opts.root` is set, it
|
|
969
|
+
* is resolved relative to that root.
|
|
970
|
+
* @param {{ root?: string }} [opts={}] - Optional root directory used to
|
|
971
|
+
* resolve relative file paths.
|
|
972
|
+
*/
|
|
973
|
+
sendFile(filePath, opts = {}) {
|
|
974
|
+
this.ensureActive();
|
|
975
|
+
try {
|
|
976
|
+
const fullPath = opts.root ? join(opts.root, filePath) : filePath;
|
|
977
|
+
const stats = statSync(fullPath);
|
|
978
|
+
if (!stats.isFile()) throw new Error("Target is not a file");
|
|
979
|
+
this.setHeader("Content-Length", String(stats.size));
|
|
980
|
+
const stream = createReadStream(fullPath);
|
|
981
|
+
this.applyPendingCookies();
|
|
982
|
+
stream.pipe(this.res);
|
|
983
|
+
stream.on("error", () => {
|
|
984
|
+
this.status(404).send({ error: "File not found" });
|
|
985
|
+
});
|
|
986
|
+
} catch {
|
|
987
|
+
this.status(404).send({ error: "File not found" });
|
|
988
|
+
} finally {
|
|
989
|
+
this._ended = true;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Serializes queued cookies into the response headers and clears the queue.
|
|
994
|
+
*
|
|
995
|
+
* Existing `Set-Cookie` headers are preserved and extended so multiple
|
|
996
|
+
* middleware and handler calls can contribute cookies before the response is
|
|
997
|
+
* finalized.
|
|
998
|
+
*/
|
|
999
|
+
applyPendingCookies() {
|
|
1000
|
+
if (this._cookies.length === 0) return;
|
|
1001
|
+
const existing = this.res.getHeader("Set-Cookie") || [];
|
|
1002
|
+
const serialized = this._cookies.map(
|
|
1003
|
+
(cookie) => serialize(cookie.name, cookie.value, cookie.options)
|
|
1004
|
+
);
|
|
1005
|
+
this.res.setHeader("Set-Cookie", [...existing, ...serialized]);
|
|
1006
|
+
this._cookies = [];
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Ensures the response has not already been finalized.
|
|
1010
|
+
*
|
|
1011
|
+
* @throws {Error} Thrown when a terminal response method has already sent or
|
|
1012
|
+
* ended the response.
|
|
1013
|
+
*/
|
|
1014
|
+
ensureActive() {
|
|
1015
|
+
if (this._ended) {
|
|
1016
|
+
throw new Error("Response has already been sent.");
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
function handleEventError(environment, socket, eventName, err) {
|
|
1021
|
+
const error = err instanceof LithiaClientError ? err : new InternalServerError(
|
|
1022
|
+
"An internal server error occurred during event processing.",
|
|
1023
|
+
err
|
|
1024
|
+
);
|
|
1025
|
+
const isProd = environment === "production";
|
|
1026
|
+
const statusCode = error.statusCode || 500;
|
|
1027
|
+
const digest = produceDigest(err);
|
|
1028
|
+
const message = isProd && statusCode >= 500 ? "Internal Server Error" : error.message;
|
|
1029
|
+
socket.emit("error", {
|
|
1030
|
+
error: {
|
|
1031
|
+
statusCode,
|
|
1032
|
+
message,
|
|
1033
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1034
|
+
digest,
|
|
1035
|
+
event: eventName,
|
|
1036
|
+
details: error.details
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
if (statusCode >= 500) {
|
|
1040
|
+
logger.error(`Digest: ${red(digest)}`);
|
|
1041
|
+
logger.info(`Event: ${eventName}`);
|
|
1042
|
+
logger.info(`Socket ID: ${socket.id}`);
|
|
1043
|
+
logger.info(err.stack || err);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// src/transport/socket/event-pipeline.ts
|
|
1048
|
+
var LithiaEventProcessor = class {
|
|
1049
|
+
/**
|
|
1050
|
+
* Creates an event processor bound to a running Lithia app instance.
|
|
1051
|
+
*
|
|
1052
|
+
* @param {LithiaApp} app - Runtime app state that provides environment and
|
|
1053
|
+
* global event middleware.
|
|
1054
|
+
*/
|
|
1055
|
+
constructor(app) {
|
|
1056
|
+
this.app = app;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Processes a single discovered realtime event.
|
|
1060
|
+
*
|
|
1061
|
+
* @param {Socket} socket - Active socket that triggered the event.
|
|
1062
|
+
* @param {Event} event - Discovered event manifest selected by the socket
|
|
1063
|
+
* transport.
|
|
1064
|
+
* @param {any} [data] - Event payload forwarded from Socket.IO.
|
|
1065
|
+
* @returns {Promise<void>} Resolves after middleware and the event handler
|
|
1066
|
+
* complete, or after the error handler emits a failure payload.
|
|
1067
|
+
*/
|
|
1068
|
+
async process(socket, event, data) {
|
|
1069
|
+
try {
|
|
1070
|
+
const module = await loadModule(event.filePath);
|
|
1071
|
+
const pipeline = [
|
|
1072
|
+
...this.app.globalEventMiddlewares,
|
|
1073
|
+
...module.middlewares || []
|
|
1074
|
+
];
|
|
1075
|
+
await executePipeline(
|
|
1076
|
+
pipeline.map(
|
|
1077
|
+
(middleware) => (next) => middleware(socket, () => next())
|
|
1078
|
+
),
|
|
1079
|
+
() => module.default(socket, data)
|
|
1080
|
+
);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
handleEventError(this.app.environment, socket, event.name, error);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
var CONTEXT_GLOBAL_KEY = /* @__PURE__ */ Symbol.for("lithia.event_context.v1");
|
|
1087
|
+
function getGlobalStore() {
|
|
1088
|
+
const globalAny = globalThis;
|
|
1089
|
+
if (!globalAny[CONTEXT_GLOBAL_KEY]) {
|
|
1090
|
+
globalAny[CONTEXT_GLOBAL_KEY] = new AsyncLocalStorage();
|
|
1091
|
+
}
|
|
1092
|
+
return globalAny[CONTEXT_GLOBAL_KEY];
|
|
1093
|
+
}
|
|
1094
|
+
var eventContextStore = getGlobalStore();
|
|
1095
|
+
|
|
1096
|
+
// src/transport/socket/socket-server.ts
|
|
1097
|
+
var LithiaSocketTransport = class {
|
|
1098
|
+
/**
|
|
1099
|
+
* Creates the Socket.IO transport and registers connection listeners.
|
|
1100
|
+
*
|
|
1101
|
+
* The constructor configures Socket.IO CORS from the app's HTTP settings and
|
|
1102
|
+
* immediately wires connection, disconnect, and custom event dispatch into
|
|
1103
|
+
* the Lithia event processor.
|
|
1104
|
+
*
|
|
1105
|
+
* @param {LithiaApp} app - Running app instance that provides config and the
|
|
1106
|
+
* discovered event manifest list.
|
|
1107
|
+
* @param {HttpServer | HttpsServer} httpServer - Underlying HTTP server used
|
|
1108
|
+
* as the Socket.IO transport base.
|
|
1109
|
+
* @param {LithiaEventProcessor} processor - Event processor responsible for
|
|
1110
|
+
* executing event middleware and handlers.
|
|
1111
|
+
*/
|
|
1112
|
+
constructor(app, httpServer, processor) {
|
|
1113
|
+
this.app = app;
|
|
1114
|
+
this.processor = processor;
|
|
1115
|
+
const { cors } = this.app.config.http;
|
|
1116
|
+
this.io = new Server(httpServer, {
|
|
1117
|
+
cors: {
|
|
1118
|
+
origin: cors.origin,
|
|
1119
|
+
methods: cors.methods,
|
|
1120
|
+
credentials: cors.credentials
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
this.io.on("connection", (socket) => {
|
|
1124
|
+
const eventMap = new Map(
|
|
1125
|
+
this.app.events.map((event) => [event.name, event])
|
|
1126
|
+
);
|
|
1127
|
+
const connectionEvent = eventMap.get("connection");
|
|
1128
|
+
if (connectionEvent) this.dispatch(socket, connectionEvent);
|
|
1129
|
+
socket.on("disconnect", (...args) => {
|
|
1130
|
+
const disconnectEvent = eventMap.get("disconnect");
|
|
1131
|
+
if (disconnectEvent) this.dispatch(socket, disconnectEvent, ...args);
|
|
1132
|
+
});
|
|
1133
|
+
socket.onAny((eventName, ...args) => {
|
|
1134
|
+
const customEvent = this.app.events.find(
|
|
1135
|
+
(event) => event.name === eventName
|
|
1136
|
+
);
|
|
1137
|
+
if (customEvent) this.dispatch(socket, customEvent, ...args);
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
io;
|
|
1142
|
+
/**
|
|
1143
|
+
* Returns the underlying Socket.IO server instance.
|
|
1144
|
+
*
|
|
1145
|
+
* @returns {SocketServer} Active Socket.IO server.
|
|
1146
|
+
*/
|
|
1147
|
+
get server() {
|
|
1148
|
+
return this.io;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Closes the Socket.IO transport and disconnects active sockets.
|
|
1152
|
+
*
|
|
1153
|
+
* @returns {Promise<void>} Resolves after Socket.IO finishes shutting down.
|
|
1154
|
+
*/
|
|
1155
|
+
async close() {
|
|
1156
|
+
await this.io.close();
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Dispatches a discovered Lithia event inside the correct runtime contexts.
|
|
1160
|
+
*
|
|
1161
|
+
* The call first enters the app-level async context and then the event
|
|
1162
|
+
* context store so event hooks can access the active socket, payload, and
|
|
1163
|
+
* event metadata during middleware and handler execution.
|
|
1164
|
+
*
|
|
1165
|
+
* @param {Socket} socket - Active socket associated with the dispatch.
|
|
1166
|
+
* @param {Event} event - Discovered event manifest to execute.
|
|
1167
|
+
* @param {any[]} args - Raw Socket.IO event arguments forwarded to the event
|
|
1168
|
+
* processor.
|
|
1169
|
+
*/
|
|
1170
|
+
dispatch(socket, event, ...args) {
|
|
1171
|
+
void this.app.runWithContext(async () => {
|
|
1172
|
+
const context = {
|
|
1173
|
+
data: args[0],
|
|
1174
|
+
socket,
|
|
1175
|
+
event
|
|
1176
|
+
};
|
|
1177
|
+
eventContextStore.run(context, async () => {
|
|
1178
|
+
await this.processor.process(socket, event, ...args);
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
// src/transport/server.ts
|
|
1185
|
+
var LithiaServer = class {
|
|
1186
|
+
/**
|
|
1187
|
+
* Creates the transport server for a running app instance.
|
|
1188
|
+
*
|
|
1189
|
+
* @param {LithiaApp} app - Runtime app that provides config, route/event
|
|
1190
|
+
* manifests, and async context helpers.
|
|
1191
|
+
*/
|
|
1192
|
+
constructor(app) {
|
|
1193
|
+
this.app = app;
|
|
1194
|
+
this.requestProcessor = new LithiaRequestProcessor(this.app);
|
|
1195
|
+
this.eventProcessor = new LithiaEventProcessor(this.app);
|
|
1196
|
+
this._httpServer = this.createServer();
|
|
1197
|
+
this.socketTransport = new LithiaSocketTransport(
|
|
1198
|
+
this.app,
|
|
1199
|
+
this._httpServer,
|
|
1200
|
+
this.eventProcessor
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
_httpServer;
|
|
1204
|
+
requestProcessor;
|
|
1205
|
+
eventProcessor;
|
|
1206
|
+
socketTransport;
|
|
1207
|
+
_activeRequests = /* @__PURE__ */ new Set();
|
|
1208
|
+
/**
|
|
1209
|
+
* Returns the set of currently open TCP connections tracked by the server.
|
|
1210
|
+
*
|
|
1211
|
+
* The set is updated from the Node.js `"connection"` event and is used during
|
|
1212
|
+
* shutdown to forcefully destroy lingering sockets after the server stops
|
|
1213
|
+
* accepting new traffic.
|
|
1214
|
+
*
|
|
1215
|
+
* @returns {Set<ActiveRequest>} Tracked open TCP sockets.
|
|
1216
|
+
*/
|
|
1217
|
+
get activeRequests() {
|
|
1218
|
+
return this._activeRequests;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Returns the underlying Node.js HTTP or HTTPS server instance.
|
|
1222
|
+
*
|
|
1223
|
+
* @returns {HttpServer | HttpsServer} Active low-level server instance.
|
|
1224
|
+
*/
|
|
1225
|
+
get httpServer() {
|
|
1226
|
+
return this._httpServer;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Returns the Socket.IO server attached to the transport.
|
|
1230
|
+
*
|
|
1231
|
+
* @returns {ReturnType<LithiaSocketTransport["server"]>} Active Socket.IO
|
|
1232
|
+
* server instance.
|
|
1233
|
+
*/
|
|
1234
|
+
get socketServer() {
|
|
1235
|
+
return this.socketTransport.server;
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Starts listening on the configured host and port.
|
|
1239
|
+
*
|
|
1240
|
+
* Repeated calls are idempotent while the underlying server is already
|
|
1241
|
+
* listening. The returned promise resolves on the low-level `"listening"`
|
|
1242
|
+
* event and rejects on the first startup error emitted by Node.js.
|
|
1243
|
+
*
|
|
1244
|
+
* @returns {Promise<void>} Resolves after the transport begins accepting
|
|
1245
|
+
* connections.
|
|
1246
|
+
*/
|
|
1247
|
+
async listen() {
|
|
1248
|
+
const { port, host } = this.app.config.http;
|
|
1249
|
+
return new Promise((resolve, reject) => {
|
|
1250
|
+
if (this._httpServer.listening) {
|
|
1251
|
+
return resolve();
|
|
1252
|
+
}
|
|
1253
|
+
const handleError = (error) => {
|
|
1254
|
+
this._httpServer.off("listening", handleListening);
|
|
1255
|
+
reject(error);
|
|
1256
|
+
};
|
|
1257
|
+
const handleListening = () => {
|
|
1258
|
+
this._httpServer.off("error", handleError);
|
|
1259
|
+
resolve();
|
|
1260
|
+
};
|
|
1261
|
+
this._httpServer.once("error", handleError);
|
|
1262
|
+
this._httpServer.once("listening", handleListening);
|
|
1263
|
+
this._httpServer.listen(port, host);
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Closes the Socket.IO transport, stops accepting HTTP traffic, and destroys
|
|
1268
|
+
* any remaining active connections.
|
|
1269
|
+
*
|
|
1270
|
+
* Socket.IO shutdown is attempted first so realtime traffic stops before the
|
|
1271
|
+
* underlying HTTP server is closed. Remaining sockets are then destroyed to
|
|
1272
|
+
* avoid hanging shutdown on keep-alive connections.
|
|
1273
|
+
*
|
|
1274
|
+
* @returns {Promise<void>} Resolves after the transport has been shut down and
|
|
1275
|
+
* tracked connections have been cleared.
|
|
1276
|
+
*/
|
|
1277
|
+
async close() {
|
|
1278
|
+
await this.socketTransport.close().catch((error) => {
|
|
1279
|
+
logger.error("Failed to close Socket.IO transport cleanly:", error);
|
|
1280
|
+
});
|
|
1281
|
+
if (!this._httpServer.listening) {
|
|
1282
|
+
for (const socket of this._activeRequests) {
|
|
1283
|
+
socket.destroy();
|
|
1284
|
+
}
|
|
1285
|
+
this._activeRequests.clear();
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
await new Promise((resolve, reject) => {
|
|
1289
|
+
this._httpServer.close((error) => {
|
|
1290
|
+
if (error) return reject(error);
|
|
1291
|
+
resolve();
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
for (const socket of this._activeRequests) {
|
|
1295
|
+
socket.destroy();
|
|
1296
|
+
}
|
|
1297
|
+
this._activeRequests.clear();
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Creates the underlying HTTP or HTTPS server instance.
|
|
1301
|
+
*
|
|
1302
|
+
* The returned server also tracks active sockets so shutdown can destroy
|
|
1303
|
+
* lingering keep-alive connections after `close()`.
|
|
1304
|
+
*
|
|
1305
|
+
* @returns {HttpServer | HttpsServer} Low-level server configured for the
|
|
1306
|
+
* current app transport.
|
|
1307
|
+
*/
|
|
1308
|
+
createServer() {
|
|
1309
|
+
const handler = this.handleRequest();
|
|
1310
|
+
const sslConfig = this.app.config.http.ssl;
|
|
1311
|
+
const server = sslConfig ? createServer(sslConfig, handler) : createServer$1(handler);
|
|
1312
|
+
server.on("connection", (socket) => {
|
|
1313
|
+
this._activeRequests.add(socket);
|
|
1314
|
+
socket.on("close", () => this._activeRequests.delete(socket));
|
|
1315
|
+
});
|
|
1316
|
+
return server;
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Creates the low-level Node request handler and bridges it into Lithia's
|
|
1320
|
+
* request context and request pipeline.
|
|
1321
|
+
*
|
|
1322
|
+
* Each incoming Node.js request is wrapped into `LithiaRequest` and
|
|
1323
|
+
* `LithiaResponse`, associated with a route context store, and then processed
|
|
1324
|
+
* inside the app-level async context so route hooks can access the active
|
|
1325
|
+
* request state.
|
|
1326
|
+
*
|
|
1327
|
+
* If request initialization itself fails before the normal request pipeline
|
|
1328
|
+
* takes over, the handler falls back to a minimal JSON `500` response.
|
|
1329
|
+
*
|
|
1330
|
+
* @returns {(req: IncomingMessage, res: ServerResponse) => void} Node.js
|
|
1331
|
+
* request listener bound to the current app instance.
|
|
1332
|
+
*/
|
|
1333
|
+
handleRequest() {
|
|
1334
|
+
return (req, res) => {
|
|
1335
|
+
try {
|
|
1336
|
+
const lithiaReq = new LithiaRequest(req, {
|
|
1337
|
+
maxBodySize: this.app.config.http.maxBodySize
|
|
1338
|
+
});
|
|
1339
|
+
const lithiaRes = new LithiaResponse(res);
|
|
1340
|
+
const routeContext = {
|
|
1341
|
+
req: lithiaReq,
|
|
1342
|
+
res: lithiaRes,
|
|
1343
|
+
socketServer: this.socketTransport.server
|
|
1344
|
+
};
|
|
1345
|
+
void this.app.runWithContext(async () => {
|
|
1346
|
+
routeContextStore.run(routeContext, async () => {
|
|
1347
|
+
await this.requestProcessor.process(lithiaReq, lithiaRes);
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
logger.error("Failed to initialize request context:", error);
|
|
1352
|
+
if (!res.headersSent) {
|
|
1353
|
+
res.statusCode = 500;
|
|
1354
|
+
res.setHeader("Content-Type", "application/json");
|
|
1355
|
+
}
|
|
1356
|
+
if (!res.writableEnded) {
|
|
1357
|
+
res.end(
|
|
1358
|
+
JSON.stringify({
|
|
1359
|
+
error: {
|
|
1360
|
+
statusCode: 500,
|
|
1361
|
+
message: "Failed to initialize request handling.",
|
|
1362
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1363
|
+
}
|
|
1364
|
+
})
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
// src/runtime/app/dependency-container.ts
|
|
1373
|
+
var DependencyContainer = class {
|
|
1374
|
+
dependencies = /* @__PURE__ */ new Map();
|
|
1375
|
+
/**
|
|
1376
|
+
* Stores a dependency by its injection key.
|
|
1377
|
+
*
|
|
1378
|
+
* @param {any} key - Injection token used to identify the dependency.
|
|
1379
|
+
* @param {T} value - Dependency instance stored under `key`.
|
|
1380
|
+
*/
|
|
1381
|
+
set(key, value) {
|
|
1382
|
+
this.dependencies.set(key, value);
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Returns whether a dependency has been registered.
|
|
1386
|
+
*
|
|
1387
|
+
* @param {any} key - Injection token to test.
|
|
1388
|
+
* @returns {boolean} `true` when the container includes `key`.
|
|
1389
|
+
*/
|
|
1390
|
+
has(key) {
|
|
1391
|
+
return this.dependencies.has(key);
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Resolves a dependency by key.
|
|
1395
|
+
*
|
|
1396
|
+
* @param {any} key - Injection token to resolve.
|
|
1397
|
+
* @returns {T | undefined} Registered dependency instance, or `undefined`
|
|
1398
|
+
* when the key is absent.
|
|
1399
|
+
*/
|
|
1400
|
+
get(key) {
|
|
1401
|
+
return this.dependencies.get(key);
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* Returns an immutable snapshot of the container contents.
|
|
1405
|
+
*
|
|
1406
|
+
* The returned `Map` is detached from future container mutations, which lets
|
|
1407
|
+
* the runtime execute work against a stable dependency view.
|
|
1408
|
+
*
|
|
1409
|
+
* @returns {Map<any, any>} Shallow copy of the current container contents.
|
|
1410
|
+
*/
|
|
1411
|
+
snapshot() {
|
|
1412
|
+
return new Map(this.dependencies);
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Returns the underlying mutable container.
|
|
1416
|
+
*
|
|
1417
|
+
* Mutating the returned `Map` mutates the container itself.
|
|
1418
|
+
*
|
|
1419
|
+
* @returns {Map<any, any>} Backing dependency map used by the runtime.
|
|
1420
|
+
*/
|
|
1421
|
+
mutable() {
|
|
1422
|
+
return this.dependencies;
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
// src/runtime/app/middleware-registry.ts
|
|
1427
|
+
var MiddlewareRegistry = class {
|
|
1428
|
+
routeMiddlewares = [];
|
|
1429
|
+
eventMiddlewares = [];
|
|
1430
|
+
/**
|
|
1431
|
+
* Registers a middleware for either the route or event pipeline.
|
|
1432
|
+
*
|
|
1433
|
+
* Route middleware is appended to the global HTTP pipeline. Event
|
|
1434
|
+
* middleware is appended to the global socket event pipeline.
|
|
1435
|
+
*
|
|
1436
|
+
* @param {"route" | "event"} context - Pipeline that should receive the
|
|
1437
|
+
* middleware.
|
|
1438
|
+
* @param {TRouteMiddleware | TEventMiddleware} middleware - Middleware
|
|
1439
|
+
* instance appended to the selected pipeline.
|
|
1440
|
+
*/
|
|
1441
|
+
use(context, middleware) {
|
|
1442
|
+
if (context === "route") {
|
|
1443
|
+
this.routeMiddlewares.push(middleware);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
this.eventMiddlewares.push(middleware);
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Returns the registered global route middlewares.
|
|
1450
|
+
*
|
|
1451
|
+
* The returned array is the live registry array and preserves registration
|
|
1452
|
+
* order.
|
|
1453
|
+
*
|
|
1454
|
+
* @returns {TRouteMiddleware[]} Registered route middlewares.
|
|
1455
|
+
*/
|
|
1456
|
+
getRoutes() {
|
|
1457
|
+
return this.routeMiddlewares;
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Returns the registered global event middlewares.
|
|
1461
|
+
*
|
|
1462
|
+
* The returned array is the live registry array and preserves registration
|
|
1463
|
+
* order.
|
|
1464
|
+
*
|
|
1465
|
+
* @returns {TEventMiddleware[]} Registered event middlewares.
|
|
1466
|
+
*/
|
|
1467
|
+
getEvents() {
|
|
1468
|
+
return this.eventMiddlewares;
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
async function fileExists(filePath) {
|
|
1472
|
+
return await access(filePath).then(() => true).catch(() => false);
|
|
1473
|
+
}
|
|
1474
|
+
async function fileHasMeaningfulModuleContent(filePath) {
|
|
1475
|
+
const source = await readFile(filePath, "utf8");
|
|
1476
|
+
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
1477
|
+
return withoutComments.trim().length > 0;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// src/runtime/app/server-bootstrap.ts
|
|
1481
|
+
async function resolveServerBootstrapPath(outDir, cwd = process.cwd()) {
|
|
1482
|
+
const candidates = [
|
|
1483
|
+
path.join(cwd, outDir, "app", "server.js"),
|
|
1484
|
+
path.join(cwd, outDir, "app", "server.mjs")
|
|
1485
|
+
];
|
|
1486
|
+
for (const candidate of candidates) {
|
|
1487
|
+
if (await fileExists(candidate) && await fileHasMeaningfulModuleContent(candidate)) {
|
|
1488
|
+
return candidate;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return null;
|
|
1492
|
+
}
|
|
1493
|
+
async function loadServerBootstrap(filePath) {
|
|
1494
|
+
const mod = await loadModule(filePath);
|
|
1495
|
+
return mod.default;
|
|
1496
|
+
}
|
|
1497
|
+
function normalizeServerBootstrapCleanup(value) {
|
|
1498
|
+
if (value === void 0) return null;
|
|
1499
|
+
if (typeof value !== "function") {
|
|
1500
|
+
throw new Error(
|
|
1501
|
+
"`app/server.ts` must return either nothing or a cleanup function."
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
return async () => {
|
|
1505
|
+
await value();
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
var TaskScheduler = class {
|
|
1509
|
+
/**
|
|
1510
|
+
* Creates a scheduler for the task manifest loaded into the current app
|
|
1511
|
+
* worker.
|
|
1512
|
+
*
|
|
1513
|
+
* @param {TaskCore[]} tasks - Task manifest entries available to the app
|
|
1514
|
+
* runtime.
|
|
1515
|
+
*/
|
|
1516
|
+
constructor(tasks) {
|
|
1517
|
+
this.tasks = tasks;
|
|
1518
|
+
}
|
|
1519
|
+
cronJobs = [];
|
|
1520
|
+
/**
|
|
1521
|
+
* Starts all CRON tasks and invokes the callback when a schedule fires.
|
|
1522
|
+
*
|
|
1523
|
+
* Each CRON task must already include a validated `schedule` string. When a
|
|
1524
|
+
* schedule triggers, the scheduler invokes `onTrigger` with the original
|
|
1525
|
+
* task metadata.
|
|
1526
|
+
*
|
|
1527
|
+
* @param {(task: TaskCore) => void} onTrigger - Callback invoked when a CRON
|
|
1528
|
+
* task schedule fires.
|
|
1529
|
+
* @throws {Error} Throws when a CRON task is missing its resolved
|
|
1530
|
+
* `schedule`.
|
|
1531
|
+
*/
|
|
1532
|
+
start(onTrigger) {
|
|
1533
|
+
const cronTasks = this.tasks.filter((task) => task.trigger === "CRON");
|
|
1534
|
+
for (const task of cronTasks) {
|
|
1535
|
+
if (!task.schedule) {
|
|
1536
|
+
throw new Error(
|
|
1537
|
+
`CRON task '${task.id}' is missing a resolved schedule.`
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
const job = cron.schedule(task.schedule, () => {
|
|
1541
|
+
onTrigger(task);
|
|
1542
|
+
});
|
|
1543
|
+
this.cronJobs.push(job);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Stops and destroys all registered CRON jobs.
|
|
1548
|
+
*
|
|
1549
|
+
* This is used during app shutdown to ensure scheduled tasks no longer fire
|
|
1550
|
+
* after the runtime begins closing.
|
|
1551
|
+
*/
|
|
1552
|
+
stop() {
|
|
1553
|
+
for (const job of this.cronJobs.splice(0)) {
|
|
1554
|
+
job.stop();
|
|
1555
|
+
if ("destroy" in job && typeof job.destroy === "function") {
|
|
1556
|
+
job.destroy();
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
// src/runtime/app/app-runtime.ts
|
|
1563
|
+
var LithiaApp = class {
|
|
1564
|
+
_environment;
|
|
1565
|
+
_config;
|
|
1566
|
+
_routes;
|
|
1567
|
+
_events;
|
|
1568
|
+
_tasks;
|
|
1569
|
+
_isFirstApp;
|
|
1570
|
+
dependencies = new DependencyContainer();
|
|
1571
|
+
middlewares = new MiddlewareRegistry();
|
|
1572
|
+
serverBootstrapCleanup = null;
|
|
1573
|
+
_server;
|
|
1574
|
+
taskScheduler;
|
|
1575
|
+
/**
|
|
1576
|
+
* Creates the app runtime from the worker payload prepared by the Lithia CLI.
|
|
1577
|
+
*
|
|
1578
|
+
* The constructor reads routes, events, tasks, config, and environment from
|
|
1579
|
+
* `workerData`, then initializes the server and task scheduler that run
|
|
1580
|
+
* inside the worker thread.
|
|
1581
|
+
*
|
|
1582
|
+
* @throws {Error} Throws when the runtime is instantiated outside a
|
|
1583
|
+
* Lithia-managed worker thread.
|
|
1584
|
+
*/
|
|
1585
|
+
constructor() {
|
|
1586
|
+
this.validateExecutionContext();
|
|
1587
|
+
this._config = workerData.config;
|
|
1588
|
+
this._routes = workerData.routes;
|
|
1589
|
+
this._events = workerData.events;
|
|
1590
|
+
this._tasks = workerData.tasks;
|
|
1591
|
+
this._environment = workerData.environment;
|
|
1592
|
+
this._isFirstApp = workerData.isFirstApp;
|
|
1593
|
+
this._server = new LithiaServer(this);
|
|
1594
|
+
this.taskScheduler = new TaskScheduler(this._tasks);
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Returns the resolved application configuration for this runtime instance.
|
|
1598
|
+
*/
|
|
1599
|
+
get config() {
|
|
1600
|
+
return this._config;
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Returns the environment mode assigned to this app worker.
|
|
1604
|
+
*/
|
|
1605
|
+
get environment() {
|
|
1606
|
+
return this._environment;
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Returns the discovered route manifest loaded into this app worker.
|
|
1610
|
+
*/
|
|
1611
|
+
get routes() {
|
|
1612
|
+
return this._routes;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Returns the discovered socket event manifest loaded into this app worker.
|
|
1616
|
+
*/
|
|
1617
|
+
get events() {
|
|
1618
|
+
return this._events;
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Returns the discovered task manifest loaded into this app worker.
|
|
1622
|
+
*
|
|
1623
|
+
* Cron-backed tasks follow the conventions described in
|
|
1624
|
+
* [Async Tasks](https://lithiajs.org/docs/latest/async-tasks).
|
|
1625
|
+
*/
|
|
1626
|
+
get tasks() {
|
|
1627
|
+
return this._tasks;
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Returns the global HTTP middlewares registered for every route pipeline.
|
|
1631
|
+
*/
|
|
1632
|
+
get globalRouteMiddlewares() {
|
|
1633
|
+
return this.middlewares.getRoutes();
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Returns the global socket middlewares registered for every event pipeline.
|
|
1637
|
+
*/
|
|
1638
|
+
get globalEventMiddlewares() {
|
|
1639
|
+
return this.middlewares.getEvents();
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Returns whether this worker is the first app instance in the current
|
|
1643
|
+
* process lifecycle.
|
|
1644
|
+
*
|
|
1645
|
+
* Lithia uses this flag to limit one-time logs and similar side effects to a
|
|
1646
|
+
* single runtime instance.
|
|
1647
|
+
*/
|
|
1648
|
+
get isFirstApp() {
|
|
1649
|
+
return this._isFirstApp;
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Runs work inside an immutable snapshot of the app dependency container.
|
|
1653
|
+
*
|
|
1654
|
+
* Use this for request handling or background work that should only resolve
|
|
1655
|
+
* dependencies that were already registered during bootstrap.
|
|
1656
|
+
*
|
|
1657
|
+
* @param {() => Promise<T>} fn - Async work to execute inside the Lithia
|
|
1658
|
+
* context.
|
|
1659
|
+
* @returns {Promise<T>} The value resolved by `fn`.
|
|
1660
|
+
*/
|
|
1661
|
+
runWithContext(fn) {
|
|
1662
|
+
return this.runWithContainer(this.dependencies.snapshot(), fn);
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Runs work inside the mutable app dependency container.
|
|
1666
|
+
*
|
|
1667
|
+
* This is primarily used during app bootstrap, when new dependencies may be
|
|
1668
|
+
* registered with `provide()`.
|
|
1669
|
+
*
|
|
1670
|
+
* This method is typically used while running `src/app/server.ts`. See
|
|
1671
|
+
* [Project Structure](https://lithiajs.org/docs/latest/project-structure)
|
|
1672
|
+
* and [Deploying](https://lithiajs.org/docs/latest/deploying).
|
|
1673
|
+
*
|
|
1674
|
+
* @param {() => Promise<T>} fn - Async work to execute inside the mutable
|
|
1675
|
+
* Lithia context.
|
|
1676
|
+
* @returns {Promise<T>} The value resolved by `fn`.
|
|
1677
|
+
*/
|
|
1678
|
+
runWithMutableContext(fn) {
|
|
1679
|
+
return this.runWithContainer(this.dependencies.mutable(), fn);
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Runs work inside a Lithia context backed by the provided dependency
|
|
1683
|
+
* container.
|
|
1684
|
+
*
|
|
1685
|
+
* @param {Map<any, any>} container - Dependency container exposed to the
|
|
1686
|
+
* current context.
|
|
1687
|
+
* @param {() => Promise<T>} fn - Async work to execute inside the context.
|
|
1688
|
+
* @returns {Promise<T>} The value resolved by `fn`.
|
|
1689
|
+
*/
|
|
1690
|
+
runWithContainer(container, fn) {
|
|
1691
|
+
const context = {
|
|
1692
|
+
container,
|
|
1693
|
+
config: this.config
|
|
1694
|
+
};
|
|
1695
|
+
return runInLithiaContext(context, fn);
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Registers a dependency in the app container.
|
|
1699
|
+
*
|
|
1700
|
+
* Dependencies registered here become available through the Lithia context
|
|
1701
|
+
* for routes, events, tasks, and startup hooks.
|
|
1702
|
+
*
|
|
1703
|
+
* @param {InjectionKey<T>} key - Token used to store and resolve the
|
|
1704
|
+
* dependency.
|
|
1705
|
+
* @param {T} value - Dependency instance associated with `key`.
|
|
1706
|
+
*/
|
|
1707
|
+
provide(key, value) {
|
|
1708
|
+
this.dependencies.set(key, value);
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Registers a global middleware for routes or events.
|
|
1712
|
+
*
|
|
1713
|
+
* Route middlewares run for every HTTP route. Event middlewares run for
|
|
1714
|
+
* every socket event.
|
|
1715
|
+
*
|
|
1716
|
+
* @param {"route" | "event"} context - Middleware pipeline that should
|
|
1717
|
+
* receive the registration.
|
|
1718
|
+
* @param {K extends "route" ? RouteMiddleware : EventMiddleware} middleware
|
|
1719
|
+
* - Middleware implementation to append to the selected global pipeline.
|
|
1720
|
+
*/
|
|
1721
|
+
use(context, middleware) {
|
|
1722
|
+
this.middlewares.use(context, middleware);
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Starts the app runtime.
|
|
1726
|
+
*
|
|
1727
|
+
* The startup sequence is:
|
|
1728
|
+
* 1. run optional `app/server.ts`
|
|
1729
|
+
* 2. start the HTTP/socket server
|
|
1730
|
+
* 3. register CRON-backed tasks
|
|
1731
|
+
* 4. announce readiness
|
|
1732
|
+
*
|
|
1733
|
+
* `app/server.ts` participates in the startup lifecycle described in
|
|
1734
|
+
* [Deploying](https://lithiajs.org/docs/latest/deploying) and
|
|
1735
|
+
* [Project Structure](https://lithiajs.org/docs/latest/project-structure).
|
|
1736
|
+
*
|
|
1737
|
+
* @returns {Promise<void>} Resolves after the server is listening and cron
|
|
1738
|
+
* tasks have been registered.
|
|
1739
|
+
* @throws {unknown} Rethrows any startup failure from bootstrap loading,
|
|
1740
|
+
* server startup, or task registration.
|
|
1741
|
+
*/
|
|
1742
|
+
async start() {
|
|
1743
|
+
this.executeOnce(() => logger.info("Starting Lithia server..."));
|
|
1744
|
+
try {
|
|
1745
|
+
await this.runServerBootstrapIfPresent();
|
|
1746
|
+
await this._server.listen();
|
|
1747
|
+
this.taskScheduler.start((task) => {
|
|
1748
|
+
parentPort?.postMessage({
|
|
1749
|
+
type: "invoke",
|
|
1750
|
+
taskId: task.id,
|
|
1751
|
+
async: true,
|
|
1752
|
+
executionId: randomUUID(),
|
|
1753
|
+
args: [],
|
|
1754
|
+
source: "CRON",
|
|
1755
|
+
attempt: 0
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
this.executeOnce(
|
|
1759
|
+
() => logger.ready(`Lithia is ready on port ${this.config.http.port}`)
|
|
1760
|
+
);
|
|
1761
|
+
} catch (error) {
|
|
1762
|
+
this.executeOnce(() => logger.error("Failed to start Lithia server."));
|
|
1763
|
+
throw error;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Stops the app runtime and runs all registered cleanup hooks.
|
|
1768
|
+
*
|
|
1769
|
+
* The shutdown sequence runs the optional cleanup returned by
|
|
1770
|
+
* `src/app/server.ts`, stops cron scheduling, and then closes the server.
|
|
1771
|
+
*
|
|
1772
|
+
* @returns {Promise<void>} Resolves after cleanup hooks and server shutdown
|
|
1773
|
+
* finish.
|
|
1774
|
+
*/
|
|
1775
|
+
async stop() {
|
|
1776
|
+
await this.runServerBootstrapCleanup();
|
|
1777
|
+
this.taskScheduler.stop();
|
|
1778
|
+
await this._server.close();
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Loads and runs `app/server.ts` when the file exists in the build output.
|
|
1782
|
+
*
|
|
1783
|
+
* If the bootstrap exports a cleanup callback, the callback is normalized
|
|
1784
|
+
* and stored for `stop()`.
|
|
1785
|
+
*
|
|
1786
|
+
* @returns {Promise<void>} Resolves after the bootstrap has finished.
|
|
1787
|
+
*/
|
|
1788
|
+
async runServerBootstrapIfPresent() {
|
|
1789
|
+
const filePath = await resolveServerBootstrapPath(this.config.outDir);
|
|
1790
|
+
if (!filePath) return;
|
|
1791
|
+
const bootstrap2 = await loadServerBootstrap(filePath);
|
|
1792
|
+
const cleanup = await this.runWithMutableContext(() => bootstrap2());
|
|
1793
|
+
this.serverBootstrapCleanup = normalizeServerBootstrapCleanup(
|
|
1794
|
+
cleanup
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Runs the cleanup returned by `app/server.ts`, if one was registered.
|
|
1799
|
+
*
|
|
1800
|
+
* Cleanup errors are logged and do not prevent the runtime from continuing
|
|
1801
|
+
* its shutdown flow.
|
|
1802
|
+
*
|
|
1803
|
+
* @returns {Promise<void>} Resolves after the cleanup callback completes or
|
|
1804
|
+
* is skipped.
|
|
1805
|
+
*/
|
|
1806
|
+
async runServerBootstrapCleanup() {
|
|
1807
|
+
if (!this.serverBootstrapCleanup) return;
|
|
1808
|
+
try {
|
|
1809
|
+
await this.serverBootstrapCleanup();
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
logger.error("Failed to clean up app/server.ts bootstrap.", error);
|
|
1812
|
+
} finally {
|
|
1813
|
+
this.serverBootstrapCleanup = null;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Restricts one-time logs to the first app instance for a given lifecycle.
|
|
1818
|
+
*
|
|
1819
|
+
* This prevents duplicated startup and failure logs when multiple app
|
|
1820
|
+
* workers share the same lifecycle but only one instance should announce
|
|
1821
|
+
* framework-level state changes.
|
|
1822
|
+
*
|
|
1823
|
+
* @param {() => void} fn - Side effect to run only for the first app
|
|
1824
|
+
* instance.
|
|
1825
|
+
*/
|
|
1826
|
+
executeOnce(fn) {
|
|
1827
|
+
if (this.isFirstApp) {
|
|
1828
|
+
fn();
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Ensures the app runtime only executes inside a Lithia-managed worker.
|
|
1833
|
+
*
|
|
1834
|
+
* The runtime depends on `workerData` prepared by the Lithia CLI and on the
|
|
1835
|
+
* worker messaging model used by the app worker entrypoint. Direct
|
|
1836
|
+
* instantiation on the main thread or inside an unrelated worker is not
|
|
1837
|
+
* supported.
|
|
1838
|
+
*
|
|
1839
|
+
* @throws {Error} Throws when the runtime runs on the main thread or inside
|
|
1840
|
+
* a worker not created by the Lithia CLI.
|
|
1841
|
+
*/
|
|
1842
|
+
validateExecutionContext() {
|
|
1843
|
+
if (isMainThread) {
|
|
1844
|
+
throw new Error(
|
|
1845
|
+
"Execution Error: LithiaApp cannot be instantiated on the main thread. It must run within a Worker Thread."
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
if (workerData?.managedBy !== "lithia") {
|
|
1849
|
+
throw new Error(
|
|
1850
|
+
"Compatibility Error: LithiaApp must be managed by the Lithia CLI. Independent execution is not supported."
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
};
|
|
1855
|
+
|
|
1856
|
+
// src/runtime/workers/app-worker.ts
|
|
1857
|
+
var isInitialized = false;
|
|
1858
|
+
async function bootstrap() {
|
|
1859
|
+
if (isInitialized) return;
|
|
1860
|
+
isInitialized = true;
|
|
1861
|
+
logger.debug("Initializing Lithia background worker...");
|
|
1862
|
+
const app = new LithiaApp();
|
|
1863
|
+
try {
|
|
1864
|
+
await app.start();
|
|
1865
|
+
parentPort?.postMessage({ type: "ready" });
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
try {
|
|
1868
|
+
await app.stop();
|
|
1869
|
+
} catch (stopError) {
|
|
1870
|
+
logger.error(
|
|
1871
|
+
"Failed to stop Lithia app cleanly after startup failure:",
|
|
1872
|
+
stopError
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
const errorPayload = error instanceof LithiaError ? {
|
|
1876
|
+
name: error.name,
|
|
1877
|
+
message: error.message,
|
|
1878
|
+
context: error.context,
|
|
1879
|
+
stack: error.stack
|
|
1880
|
+
} : {
|
|
1881
|
+
name: error?.name ?? "UnknownWorkerError",
|
|
1882
|
+
message: String(error?.message ?? error),
|
|
1883
|
+
stack: error?.stack
|
|
1884
|
+
};
|
|
1885
|
+
parentPort?.postMessage({ type: "error", error: errorPayload });
|
|
1886
|
+
if (!(error instanceof LithiaError)) {
|
|
1887
|
+
throw error;
|
|
1888
|
+
}
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const shutdown = async () => {
|
|
1892
|
+
try {
|
|
1893
|
+
await app.stop();
|
|
1894
|
+
} catch {
|
|
1895
|
+
} finally {
|
|
1896
|
+
process.exit(0);
|
|
1897
|
+
}
|
|
1898
|
+
};
|
|
1899
|
+
process.once("SIGINT", shutdown);
|
|
1900
|
+
process.once("SIGTERM", shutdown);
|
|
1901
|
+
}
|
|
1902
|
+
bootstrap().catch((error) => {
|
|
1903
|
+
logger.error("Fatal exception in Lithia worker:", error);
|
|
1904
|
+
process.exit(1);
|
|
1905
|
+
});
|
|
1906
|
+
//# sourceMappingURL=app-worker.mjs.map
|
|
1907
|
+
//# sourceMappingURL=app-worker.mjs.map
|