@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
package/dist/_index.mjs
ADDED
|
@@ -0,0 +1,2106 @@
|
|
|
1
|
+
import fs4, { readFile, rm, access } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { parseEnv } from 'util';
|
|
4
|
+
import { isMainThread, Worker } from 'worker_threads';
|
|
5
|
+
import { logger, green, red } from '@lithia-js/utils';
|
|
6
|
+
import sms from 'source-map-support';
|
|
7
|
+
import { createRequire } from 'module';
|
|
8
|
+
import { pathToFileURL } from 'url';
|
|
9
|
+
import fg from 'fast-glob';
|
|
10
|
+
import cron from 'node-cron';
|
|
11
|
+
import * as swc from '@swc/core';
|
|
12
|
+
import { loadConfig as loadConfig$1 } from 'c12';
|
|
13
|
+
import { klona } from 'klona';
|
|
14
|
+
|
|
15
|
+
// src/errors/base.ts
|
|
16
|
+
var LithiaError = class extends Error {
|
|
17
|
+
isLithiaError = true;
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = this.constructor.name;
|
|
21
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var LithiaClientError = class extends LithiaError {
|
|
25
|
+
constructor(message, statusCode, details) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.statusCode = statusCode;
|
|
28
|
+
this.details = details;
|
|
29
|
+
}
|
|
30
|
+
timestamp = /* @__PURE__ */ new Date();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// src/errors/app/client.ts
|
|
34
|
+
var RouteNotFoundError = class extends LithiaClientError {
|
|
35
|
+
constructor(m, d) {
|
|
36
|
+
super(m, 404, d);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// src/errors/internal/context.ts
|
|
41
|
+
var NotInLithiaContextError = class extends LithiaError {
|
|
42
|
+
constructor() {
|
|
43
|
+
super("Lithia hooks must be used within a managed invocation.");
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// src/errors/internal/loader.ts
|
|
48
|
+
var ManifestVersionMismatchError = class extends LithiaError {
|
|
49
|
+
constructor(expectedVersion, foundVersion) {
|
|
50
|
+
super(
|
|
51
|
+
`Manifest version mismatch: expected '${expectedVersion}', but found '${foundVersion}'. Rebuild required.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// package.json
|
|
57
|
+
var package_default = {
|
|
58
|
+
version: "1.0.0-canary.21"};
|
|
59
|
+
|
|
60
|
+
// src/meta.ts
|
|
61
|
+
var version = package_default.version;
|
|
62
|
+
|
|
63
|
+
// src/discovery/events.ts
|
|
64
|
+
var withBase = (targetPath, base) => {
|
|
65
|
+
if (!base || base === "/") return targetPath;
|
|
66
|
+
return `${base.replace(/\/$/, "")}/${targetPath.replace(/^\//, "")}`;
|
|
67
|
+
};
|
|
68
|
+
var EventConvention = class {
|
|
69
|
+
/**
|
|
70
|
+
* Removes the leading event root from a discovered event file path.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} filePath - Relative file path returned by the scanner.
|
|
73
|
+
* @returns {string} Event-relative path used for name normalization.
|
|
74
|
+
*/
|
|
75
|
+
extractEventPath(filePath) {
|
|
76
|
+
return filePath.replace(/\\/g, "/").replace(/^(app\/)?events\//, "");
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
var EventPathTransformer = class {
|
|
80
|
+
removeExt = /\.(mts|mjs|ts|js)$/i;
|
|
81
|
+
removeGroups = /\(([^([\\/]+)\)[\\/]/g;
|
|
82
|
+
/**
|
|
83
|
+
* Removes extensions, grouping segments, duplicate separators, and leading
|
|
84
|
+
* or trailing slashes from an event path fragment.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} pathStr - Event-relative path fragment to normalize.
|
|
87
|
+
* @returns {string} Canonical event path without file extension or groups.
|
|
88
|
+
*/
|
|
89
|
+
normalize(pathStr) {
|
|
90
|
+
const normalized = pathStr.replace(/\\/g, "/").replace(this.removeExt, "").replace(this.removeGroups, "");
|
|
91
|
+
return normalized.replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Converts a normalized path into an absolute path with an optional global
|
|
95
|
+
* prefix.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} pathStr - Path fragment to prefix and canonicalize.
|
|
98
|
+
* @param {string} globalPrefix - Optional base path applied before
|
|
99
|
+
* normalization.
|
|
100
|
+
* @returns {string} Absolute path with a single leading slash and no
|
|
101
|
+
* trailing slash unless the path is root.
|
|
102
|
+
*/
|
|
103
|
+
normalizePath(pathStr, globalPrefix = "") {
|
|
104
|
+
const combined = withBase(pathStr, globalPrefix);
|
|
105
|
+
const noTrailing = combined.endsWith("/") && combined.length > 1 ? combined.slice(0, -1) : combined;
|
|
106
|
+
return noTrailing.startsWith("/") ? noTrailing : `/${noTrailing}`;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var EventProcessor = class {
|
|
110
|
+
constructor(transformer = new EventPathTransformer(), convention = new EventConvention()) {
|
|
111
|
+
this.transformer = transformer;
|
|
112
|
+
this.convention = convention;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Processes multiple discovered event files into manifest entries.
|
|
116
|
+
*
|
|
117
|
+
* @param {FileInfo[]} files - Discovered event files to transform.
|
|
118
|
+
* @returns {Event[]} Runtime event entries derived from the input files.
|
|
119
|
+
*/
|
|
120
|
+
process(files) {
|
|
121
|
+
return files.map((file) => this.processEventFile(file));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Resolves one discovered event file into a runtime manifest entry.
|
|
125
|
+
*
|
|
126
|
+
* Single-segment files produce bare event names such as `connection` or
|
|
127
|
+
* `disconnect`. Nested files produce colon-delimited names such as
|
|
128
|
+
* `chat:ping`, except for trailing `connection` and `disconnect`, which keep
|
|
129
|
+
* their lifecycle names.
|
|
130
|
+
*
|
|
131
|
+
* @param {FileInfo} file - Discovered event file to transform.
|
|
132
|
+
* @returns {Event} Manifest entry used by the socket runtime.
|
|
133
|
+
*/
|
|
134
|
+
processEventFile(file) {
|
|
135
|
+
const intermediate = this.convention.extractEventPath(file.path);
|
|
136
|
+
const normalized = this.transformer.normalize(intermediate);
|
|
137
|
+
const parts = normalized.split("/").filter((part) => part.length > 0);
|
|
138
|
+
let eventName = "";
|
|
139
|
+
if (parts.length === 1) {
|
|
140
|
+
eventName = parts[0];
|
|
141
|
+
} else if (parts.length > 1) {
|
|
142
|
+
const last = parts[parts.length - 1];
|
|
143
|
+
eventName = last === "connection" || last === "disconnect" ? last : parts.join(":");
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
name: eventName,
|
|
147
|
+
filePath: file.fullPath,
|
|
148
|
+
namespace: eventName.includes(":") ? eventName.split(":")[0] : null
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
var EventManifestGenerator = class {
|
|
153
|
+
constructor(processor = new EventProcessor()) {
|
|
154
|
+
this.processor = processor;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Generates `events.json` from scanned build output files.
|
|
158
|
+
*
|
|
159
|
+
* The generator filters scanned files to event handler locations, converts
|
|
160
|
+
* them into runtime event entries, and writes a versioned manifest that is
|
|
161
|
+
* later loaded by the host runtime.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} outRoot - Build output directory that receives the
|
|
164
|
+
* manifest.
|
|
165
|
+
* @param {FileInfo[]} scannedFiles - Files scanned from the compiled output
|
|
166
|
+
* tree.
|
|
167
|
+
* @returns {Promise<EventsManifest | null>} The generated manifest, or
|
|
168
|
+
* `null` when no event files are present.
|
|
169
|
+
* @throws {Error} Throws when the manifest directory cannot be created or
|
|
170
|
+
* the manifest file cannot be written.
|
|
171
|
+
*/
|
|
172
|
+
async generateManifest(outRoot, scannedFiles) {
|
|
173
|
+
const eventFiles = scannedFiles.filter((file) => {
|
|
174
|
+
const normalized = file.path.split(path.sep).join("/");
|
|
175
|
+
return normalized.includes("events/") || normalized.includes("app/events/");
|
|
176
|
+
});
|
|
177
|
+
if (eventFiles.length === 0) return null;
|
|
178
|
+
const events = this.processor.process(eventFiles);
|
|
179
|
+
const manifest = { version, events };
|
|
180
|
+
const manifestPath = path.join(outRoot, "events.json");
|
|
181
|
+
try {
|
|
182
|
+
await fs4.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
183
|
+
await fs4.writeFile(
|
|
184
|
+
manifestPath,
|
|
185
|
+
JSON.stringify(manifest, null, 2),
|
|
186
|
+
"utf-8"
|
|
187
|
+
);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw new Error(`Failed to write events manifest: ${error}`);
|
|
190
|
+
}
|
|
191
|
+
return manifest;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
var withBase2 = (routePath, base) => {
|
|
195
|
+
if (!base || base === "/") return routePath;
|
|
196
|
+
return `${base.replace(/\/$/, "")}/${routePath.replace(/^\//, "")}`;
|
|
197
|
+
};
|
|
198
|
+
var withLeadingSlash = (routePath) => routePath.startsWith("/") ? routePath : `/${routePath}`;
|
|
199
|
+
var withoutTrailingSlash = (routePath) => routePath.endsWith("/") && routePath.length > 1 ? routePath.slice(0, -1) : routePath;
|
|
200
|
+
var RouteConvention = class {
|
|
201
|
+
routeRegex = /(?:^|[\\/])route(\.(delete|get|head|options|patch|post|put))?\.(mts|mjs|ts|js)$/i;
|
|
202
|
+
/**
|
|
203
|
+
* Extracts the optional HTTP method suffix from a route file path.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} filePath - Route-relative file path returned by the
|
|
206
|
+
* scanner.
|
|
207
|
+
* @returns {ExtractedMethod} Inferred method and the remaining logical path.
|
|
208
|
+
*/
|
|
209
|
+
extractMethod(filePath) {
|
|
210
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
211
|
+
const match = normalizedPath.match(this.routeRegex);
|
|
212
|
+
const methodStr = match?.[2]?.toUpperCase();
|
|
213
|
+
const rawPath = normalizedPath.replace(this.routeRegex, "");
|
|
214
|
+
return {
|
|
215
|
+
method: methodStr || null,
|
|
216
|
+
updatedPath: rawPath
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
var RoutePathTransformer = class {
|
|
221
|
+
removeExt = /\.(mts|mjs|ts|js)$/i;
|
|
222
|
+
removeGroups = /\(([^([\\/]+)\)[\\/]/g;
|
|
223
|
+
catchAllNamed = /\[\.\.\.(\w+)\]/g;
|
|
224
|
+
catchAll = /\[\.\.\.\]/g;
|
|
225
|
+
dynamic = /\[([^/\]]+)\]/g;
|
|
226
|
+
dynamicDetector = /:\w+|\*\*/;
|
|
227
|
+
routeParam = /:(\w+)/g;
|
|
228
|
+
/**
|
|
229
|
+
* Converts a route file path into Lithia's internal route path format.
|
|
230
|
+
*
|
|
231
|
+
* Grouping segments are removed, `[param]` becomes `:param`, `[...name]`
|
|
232
|
+
* becomes `**:name`, and `[...]` becomes `**`.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} filePath - Route path fragment after removing the route
|
|
235
|
+
* filename pattern.
|
|
236
|
+
* @returns {string} Internal route path used by later normalization steps.
|
|
237
|
+
*/
|
|
238
|
+
transformFilePath(filePath) {
|
|
239
|
+
let result = filePath.replace(/\\/g, "/").replace(this.removeExt, "").replace(this.removeGroups, "");
|
|
240
|
+
result = result.replace(this.catchAllNamed, "**:$1");
|
|
241
|
+
result = result.replace(this.catchAll, "**");
|
|
242
|
+
result = result.replace(this.dynamic, ":$1");
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Converts an internal route path into a canonical public route path.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} pathStr - Internal route path to normalize.
|
|
249
|
+
* @param {string} globalPrefix - Optional global prefix applied before
|
|
250
|
+
* normalization.
|
|
251
|
+
* @returns {string} Public route path with a leading slash and no trailing
|
|
252
|
+
* slash unless the path is root.
|
|
253
|
+
*/
|
|
254
|
+
normalizePath(pathStr, globalPrefix = "") {
|
|
255
|
+
const combined = withBase2(pathStr, globalPrefix);
|
|
256
|
+
const noTrailing = withoutTrailingSlash(combined);
|
|
257
|
+
return withLeadingSlash(noTrailing);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Detects whether a route path contains dynamic or catch-all segments.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} pathStr - Canonical route path to inspect.
|
|
263
|
+
* @returns {boolean} `true` when the route contains `:param` or `**`
|
|
264
|
+
* segments.
|
|
265
|
+
*/
|
|
266
|
+
isDynamicRoute(pathStr) {
|
|
267
|
+
return this.dynamicDetector.test(pathStr);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Generates the runtime matcher regex source for a canonical route path.
|
|
271
|
+
*
|
|
272
|
+
* Named parameters become single-segment capture groups, and catch-all
|
|
273
|
+
* segments become greedy capture groups.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} pathStr - Canonical route path to convert.
|
|
276
|
+
* @returns {string} Anchored regex source used by the route matcher.
|
|
277
|
+
*/
|
|
278
|
+
generateRouteRegex(pathStr) {
|
|
279
|
+
let escaped = pathStr.replace(/\//g, "\\/");
|
|
280
|
+
escaped = escaped.replace(/\*\*:\w+/g, "(.*)");
|
|
281
|
+
escaped = escaped.replace(/\*\*/g, "(.*)");
|
|
282
|
+
const regexBody = escaped.replace(this.routeParam, "([^\\/]+)");
|
|
283
|
+
return `^${regexBody}$`;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
var RouteProcessor = class {
|
|
287
|
+
constructor(transformer = new RoutePathTransformer(), convention = new RouteConvention()) {
|
|
288
|
+
this.transformer = transformer;
|
|
289
|
+
this.convention = convention;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Resolves one discovered route file into a runtime manifest entry.
|
|
293
|
+
*
|
|
294
|
+
* The processor strips the route root, extracts the optional method suffix,
|
|
295
|
+
* normalizes dynamic and catch-all segments, computes whether the route is
|
|
296
|
+
* dynamic, and generates the regex source used by request matching.
|
|
297
|
+
*
|
|
298
|
+
* @param {FileInfo} file - Discovered route file to transform.
|
|
299
|
+
* @returns {Route} Manifest entry consumed by the HTTP runtime.
|
|
300
|
+
*/
|
|
301
|
+
processRouteFile(file) {
|
|
302
|
+
const logicalPath = file.path.replace(/^(.*[\\/])?routes[\\/]/, "");
|
|
303
|
+
const extracted = this.convention.extractMethod(logicalPath);
|
|
304
|
+
const internalPath = this.transformer.transformFilePath(
|
|
305
|
+
extracted.updatedPath
|
|
306
|
+
);
|
|
307
|
+
const finalPath = this.transformer.normalizePath(internalPath, "");
|
|
308
|
+
const dynamic = this.transformer.isDynamicRoute(finalPath);
|
|
309
|
+
const regex = this.transformer.generateRouteRegex(finalPath);
|
|
310
|
+
return {
|
|
311
|
+
method: extracted.method?.toString(),
|
|
312
|
+
path: finalPath,
|
|
313
|
+
dynamic,
|
|
314
|
+
filePath: file.fullPath,
|
|
315
|
+
regex
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
var RouteManifestGenerator = class {
|
|
320
|
+
constructor(processor = new RouteProcessor()) {
|
|
321
|
+
this.processor = processor;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Generates `routes.json` from scanned build output files.
|
|
325
|
+
*
|
|
326
|
+
* The generator filters scanned files to route handler locations, converts
|
|
327
|
+
* them into runtime route entries, and writes a versioned manifest that is
|
|
328
|
+
* later loaded by the host runtime.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} outRoot - Build output directory that receives the
|
|
331
|
+
* manifest.
|
|
332
|
+
* @param {FileInfo[]} scannedFiles - Files scanned from the compiled output
|
|
333
|
+
* tree.
|
|
334
|
+
* @returns {Promise<RoutesManifest>} Generated route manifest.
|
|
335
|
+
* @throws {Error} Throws when the manifest directory cannot be created or
|
|
336
|
+
* the manifest file cannot be written.
|
|
337
|
+
*/
|
|
338
|
+
async generateManifest(outRoot, scannedFiles) {
|
|
339
|
+
const routeFiles = scannedFiles.filter((file) => {
|
|
340
|
+
const normalized = file.path.split(path.sep).join("/");
|
|
341
|
+
return normalized.includes("routes/") || normalized.includes("app/routes/");
|
|
342
|
+
});
|
|
343
|
+
const routes = routeFiles.map(
|
|
344
|
+
(file) => this.processor.processRouteFile(file)
|
|
345
|
+
);
|
|
346
|
+
const manifest = { version, routes };
|
|
347
|
+
const manifestPath = path.join(outRoot, "routes.json");
|
|
348
|
+
try {
|
|
349
|
+
await fs4.mkdir(outRoot, { recursive: true });
|
|
350
|
+
await fs4.writeFile(
|
|
351
|
+
manifestPath,
|
|
352
|
+
JSON.stringify(manifest, null, 2),
|
|
353
|
+
"utf-8"
|
|
354
|
+
);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
throw new Error(`Failed to write routes manifest: ${error}`);
|
|
357
|
+
}
|
|
358
|
+
return manifest;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
var FileScanner = class {
|
|
362
|
+
/**
|
|
363
|
+
* Scans the target directory and returns normalized file metadata.
|
|
364
|
+
*
|
|
365
|
+
* The scanner resolves the target directory from `process.cwd()`, applies
|
|
366
|
+
* include and ignore globs through `fast-glob`, returns only files, excludes
|
|
367
|
+
* dotfiles, normalizes relative paths to forward slashes, and sorts the
|
|
368
|
+
* result by relative path for deterministic downstream processing.
|
|
369
|
+
*
|
|
370
|
+
* @param {string[]} pathComponents - Path segments resolved from the current
|
|
371
|
+
* working directory to the scan root.
|
|
372
|
+
* @param {ScanOptions} options - Optional include and ignore glob patterns.
|
|
373
|
+
* @returns {Promise<FileInfo[]>} Sorted file metadata entries relative to
|
|
374
|
+
* the scan root.
|
|
375
|
+
*/
|
|
376
|
+
async scanDir(pathComponents, options = {}) {
|
|
377
|
+
const targetPath = path.resolve(process.cwd(), ...pathComponents);
|
|
378
|
+
const patterns = options.include && options.include.length > 0 ? options.include : ["**/*.{ts,js,mts,mjs}"];
|
|
379
|
+
const entries = await fg(patterns, {
|
|
380
|
+
cwd: targetPath,
|
|
381
|
+
ignore: options.ignore ?? [],
|
|
382
|
+
absolute: true,
|
|
383
|
+
onlyFiles: true,
|
|
384
|
+
dot: false
|
|
385
|
+
});
|
|
386
|
+
const fileInfos = entries.map((fullPath) => ({
|
|
387
|
+
path: path.relative(targetPath, fullPath).replace(/\\/g, "/"),
|
|
388
|
+
fullPath
|
|
389
|
+
}));
|
|
390
|
+
return fileInfos.sort((a, b) => a.path.localeCompare(b.path));
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
async function fileExists(filePath) {
|
|
394
|
+
return await access(filePath).then(() => true).catch(() => false);
|
|
395
|
+
}
|
|
396
|
+
async function fileHasMeaningfulModuleContent(filePath) {
|
|
397
|
+
const source = await readFile(filePath, "utf8");
|
|
398
|
+
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
399
|
+
return withoutComments.trim().length > 0;
|
|
400
|
+
}
|
|
401
|
+
function toOutputFilePath(root, relativePath) {
|
|
402
|
+
return path.join(root, relativePath).replace(/\.ts$/, ".js").replace(/\.mts$/, ".mjs");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/discovery/tasks.ts
|
|
406
|
+
var TaskConvention = class {
|
|
407
|
+
taskRegex = /^(.*?)(?:\.(cron))?\.(mts|mjs|ts|js)$/i;
|
|
408
|
+
/**
|
|
409
|
+
* Extracts the task trigger type and raw identifier from a task file path.
|
|
410
|
+
*
|
|
411
|
+
* The optional `.cron` marker changes the trigger from `ON_DEMAND` to
|
|
412
|
+
* `CRON`.
|
|
413
|
+
*
|
|
414
|
+
* @param {string} filePath - Task-relative file path returned by the
|
|
415
|
+
* scanner.
|
|
416
|
+
* @returns {ExtractedTask} Task trigger metadata derived from the filename.
|
|
417
|
+
*/
|
|
418
|
+
extractTask(filePath) {
|
|
419
|
+
const cleanPath = filePath.replace(/\\/g, "/").replace(/^(app\/)?tasks\//, "");
|
|
420
|
+
const match = cleanPath.match(this.taskRegex);
|
|
421
|
+
const isCron = match?.[2]?.toLowerCase() === "cron";
|
|
422
|
+
const rawName = match?.[1] || cleanPath;
|
|
423
|
+
return {
|
|
424
|
+
trigger: isCron ? "CRON" : "ON_DEMAND",
|
|
425
|
+
rawName
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
var TaskPathTransformer = class {
|
|
430
|
+
removeGroups = /\(([^([\\/]+)\)[\\/]/g;
|
|
431
|
+
/**
|
|
432
|
+
* Converts a raw task path into Lithia's colon-delimited task identifier.
|
|
433
|
+
*
|
|
434
|
+
* Grouping segments are removed and remaining path segments are joined with
|
|
435
|
+
* colons.
|
|
436
|
+
*
|
|
437
|
+
* @param {string} rawName - Task-relative name extracted from the file path.
|
|
438
|
+
* @returns {string} Stable runtime task identifier.
|
|
439
|
+
*/
|
|
440
|
+
normalizeIdentifier(rawName) {
|
|
441
|
+
const withoutGroups = rawName.replace(this.removeGroups, "");
|
|
442
|
+
return withoutGroups.replace(/\\/g, "/").split("/").filter((part) => part.length > 0).join(":");
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Converts a task identifier into a human-readable display label.
|
|
446
|
+
*
|
|
447
|
+
* @param {string} identifier - Colon-delimited task identifier.
|
|
448
|
+
* @returns {string} Space-delimited display name with capitalized segments.
|
|
449
|
+
*/
|
|
450
|
+
formatDisplayName(identifier) {
|
|
451
|
+
return identifier.split(":").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
var TaskProcessor = class {
|
|
455
|
+
constructor(convention = new TaskConvention(), transformer = new TaskPathTransformer()) {
|
|
456
|
+
this.convention = convention;
|
|
457
|
+
this.transformer = transformer;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Processes multiple discovered task files into manifest entries.
|
|
461
|
+
*
|
|
462
|
+
* @param {FileInfo[]} files - Discovered task files to transform.
|
|
463
|
+
* @returns {TaskCore[]} Runtime task entries derived from the input files.
|
|
464
|
+
*/
|
|
465
|
+
process(files) {
|
|
466
|
+
return files.map((file) => this.processTaskFile(file));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Resolves one discovered task file into a runtime manifest entry.
|
|
470
|
+
*
|
|
471
|
+
* CRON metadata such as `schedule` and `retries` is attached later during
|
|
472
|
+
* manifest generation after the module can be loaded from the build output.
|
|
473
|
+
*
|
|
474
|
+
* @param {FileInfo} file - Discovered task file to transform.
|
|
475
|
+
* @returns {TaskCore} Manifest entry with identifier, trigger, and module
|
|
476
|
+
* path.
|
|
477
|
+
*/
|
|
478
|
+
processTaskFile(file) {
|
|
479
|
+
const extracted = this.convention.extractTask(file.path);
|
|
480
|
+
const id = this.transformer.normalizeIdentifier(extracted.rawName);
|
|
481
|
+
return {
|
|
482
|
+
id,
|
|
483
|
+
trigger: extracted.trigger,
|
|
484
|
+
filePath: file.fullPath,
|
|
485
|
+
schedule: void 0,
|
|
486
|
+
retries: void 0
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
var TaskManifestGenerator = class {
|
|
491
|
+
constructor(processor = new TaskProcessor()) {
|
|
492
|
+
this.processor = processor;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Generates `tasks.json` from scanned build output files.
|
|
496
|
+
*
|
|
497
|
+
* The generator filters scanned files to task handler locations, converts
|
|
498
|
+
* them into runtime task entries, resolves CRON metadata from compiled task
|
|
499
|
+
* modules, and writes a versioned manifest that is later loaded by the host
|
|
500
|
+
* runtime.
|
|
501
|
+
*
|
|
502
|
+
* @param {string} outRoot - Build output directory that receives the
|
|
503
|
+
* manifest.
|
|
504
|
+
* @param {FileInfo[]} scannedFiles - Files scanned from the compiled output
|
|
505
|
+
* tree.
|
|
506
|
+
* @returns {Promise<TasksManifest | null>} The generated manifest, or
|
|
507
|
+
* `null` when no task files are present.
|
|
508
|
+
* @throws {Error} Throws when task metadata is invalid or when the manifest
|
|
509
|
+
* file cannot be written.
|
|
510
|
+
*/
|
|
511
|
+
async generateManifest(outRoot, scannedFiles) {
|
|
512
|
+
const taskFiles = scannedFiles.filter((file) => {
|
|
513
|
+
const normalized = file.path.split(path.sep).join("/");
|
|
514
|
+
return normalized.includes("tasks/") || normalized.includes("app/tasks/");
|
|
515
|
+
});
|
|
516
|
+
if (taskFiles.length === 0) return null;
|
|
517
|
+
const tasks = await this.attachCronSchedules(
|
|
518
|
+
this.processor.process(taskFiles)
|
|
519
|
+
);
|
|
520
|
+
const manifest = { version, tasks };
|
|
521
|
+
const manifestPath = path.join(outRoot, "tasks.json");
|
|
522
|
+
try {
|
|
523
|
+
await fs4.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
524
|
+
await fs4.writeFile(
|
|
525
|
+
manifestPath,
|
|
526
|
+
JSON.stringify(manifest, null, 2),
|
|
527
|
+
"utf-8"
|
|
528
|
+
);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
throw new Error(`Failed to write tasks manifest: ${error}`);
|
|
531
|
+
}
|
|
532
|
+
return manifest;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Loads CRON metadata for discovered tasks and removes empty task modules.
|
|
536
|
+
*
|
|
537
|
+
* Files without meaningful module content are skipped entirely. CRON tasks
|
|
538
|
+
* are dynamically imported so their `schedule` and optional `retries`
|
|
539
|
+
* exports can be validated and attached to the manifest.
|
|
540
|
+
*
|
|
541
|
+
* @param {TaskCore[]} tasks - Task entries produced by the task processor.
|
|
542
|
+
* @returns {Promise<TaskCore[]>} Task entries ready to be written to the
|
|
543
|
+
* manifest.
|
|
544
|
+
* @throws {Error} Throws when a CRON task exports an invalid schedule.
|
|
545
|
+
*/
|
|
546
|
+
async attachCronSchedules(tasks) {
|
|
547
|
+
const resolvedTasks = await Promise.all(
|
|
548
|
+
tasks.map(async (task) => {
|
|
549
|
+
if (!await fileHasMeaningfulModuleContent(task.filePath)) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
if (task.trigger !== "CRON") return task;
|
|
553
|
+
const { schedule, retries } = await this.readCronConfig(task.filePath);
|
|
554
|
+
if (!cron.validate(schedule)) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`Invalid cron schedule '${schedule}' for task '${task.id}'.`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
...task,
|
|
561
|
+
schedule,
|
|
562
|
+
retries
|
|
563
|
+
};
|
|
564
|
+
})
|
|
565
|
+
);
|
|
566
|
+
return resolvedTasks.filter((task) => task !== null);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Reads and validates CRON-specific exports from a compiled task module.
|
|
570
|
+
*
|
|
571
|
+
* The module is imported with a cache-busting query string so repeated build
|
|
572
|
+
* runs do not reuse a stale module instance.
|
|
573
|
+
*
|
|
574
|
+
* @param {string} filePath - Compiled task module path to import.
|
|
575
|
+
* @returns {Promise<{ schedule: string; retries: number }>} Validated CRON
|
|
576
|
+
* configuration attached to the task manifest.
|
|
577
|
+
* @throws {Error} Throws when `schedule` is missing or not a string, or when
|
|
578
|
+
* `retries` is not a non-negative integer.
|
|
579
|
+
*/
|
|
580
|
+
async readCronConfig(filePath) {
|
|
581
|
+
const fileUrl = new URL(pathToFileURL(filePath).href);
|
|
582
|
+
fileUrl.searchParams.set("t", `${Date.now()}`);
|
|
583
|
+
const mod = await import(fileUrl.href);
|
|
584
|
+
if (!mod.schedule || typeof mod.schedule !== "string") {
|
|
585
|
+
throw new Error(
|
|
586
|
+
`CRON task '${filePath}' must export 'schedule' as a string.`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
if (mod.retries !== void 0 && (!Number.isInteger(mod.retries) || mod.retries < 0)) {
|
|
590
|
+
throw new Error(
|
|
591
|
+
`CRON task '${filePath}' must export 'retries' as a non-negative integer.`
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
schedule: mod.schedule,
|
|
596
|
+
retries: mod.retries ?? 0
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
async function compileSourceFiles(files, config) {
|
|
601
|
+
const { compilerOptions } = await fs4.readFile(path.join(process.cwd(), "tsconfig.json"), "utf-8").then((data) => JSON.parse(data));
|
|
602
|
+
await Promise.all(
|
|
603
|
+
files.map(async (file) => {
|
|
604
|
+
const targetPath = toOutputFilePath(config.outRoot, file.path);
|
|
605
|
+
await fs4.mkdir(path.dirname(targetPath), { recursive: true });
|
|
606
|
+
const output = await swc.transformFile(file.fullPath, {
|
|
607
|
+
jsc: {
|
|
608
|
+
parser: {
|
|
609
|
+
syntax: "typescript",
|
|
610
|
+
dynamicImport: true
|
|
611
|
+
},
|
|
612
|
+
target: "esnext",
|
|
613
|
+
baseUrl: path.resolve(process.cwd(), compilerOptions.baseUrl || "."),
|
|
614
|
+
paths: {
|
|
615
|
+
...compilerOptions.paths || {}
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
module: {
|
|
619
|
+
type: "es6",
|
|
620
|
+
resolveFully: true
|
|
621
|
+
},
|
|
622
|
+
sourceMaps: true
|
|
623
|
+
});
|
|
624
|
+
await fs4.writeFile(targetPath, output.code);
|
|
625
|
+
if (output.map) {
|
|
626
|
+
await fs4.writeFile(`${targetPath}.map`, output.map);
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
async function generateLithiaTypes(projectRoot, registry) {
|
|
632
|
+
const dotLithiaDir = path.join(projectRoot, ".lithia");
|
|
633
|
+
const imports = [];
|
|
634
|
+
const moduleAugmentations = [];
|
|
635
|
+
for (const [category, definitions] of Object.entries(registry)) {
|
|
636
|
+
if (!definitions || definitions.length === 0) continue;
|
|
637
|
+
const interfaceName = `Lithia${category.charAt(0).toUpperCase() + category.slice(1)}`;
|
|
638
|
+
const interfaceLines = [];
|
|
639
|
+
for (const def of definitions) {
|
|
640
|
+
const typeAlias = `${category}_${toPascalCase(def.identifier)}`;
|
|
641
|
+
const importPath = relativeImportPath(dotLithiaDir, def.filePath);
|
|
642
|
+
const member = def.exportName || "default";
|
|
643
|
+
imports.push(
|
|
644
|
+
`import { ${member} as ${typeAlias} } from "${importPath}";`
|
|
645
|
+
);
|
|
646
|
+
interfaceLines.push(` "${def.identifier}": typeof ${typeAlias};`);
|
|
647
|
+
}
|
|
648
|
+
moduleAugmentations.push(
|
|
649
|
+
` interface ${interfaceName} {
|
|
650
|
+
${interfaceLines.join("\n")}
|
|
651
|
+
}`
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
const content = [
|
|
655
|
+
"/* eslint-disable */",
|
|
656
|
+
"/* This file is auto-generated by Lithia. Do not edit manually. */",
|
|
657
|
+
imports.join("\n"),
|
|
658
|
+
'\ndeclare module "@lithia-js/core" {',
|
|
659
|
+
moduleAugmentations.join("\n\n"),
|
|
660
|
+
"}"
|
|
661
|
+
].join("\n");
|
|
662
|
+
const lithiaTypesPath = path.join(dotLithiaDir, "lithia.d.ts");
|
|
663
|
+
await fs4.mkdir(dotLithiaDir, { recursive: true });
|
|
664
|
+
await fs4.writeFile(lithiaTypesPath, content, "utf-8");
|
|
665
|
+
}
|
|
666
|
+
function toPascalCase(str) {
|
|
667
|
+
return str.replace(/[^a-zA-Z0-9]/g, "-").replace(/(^\w|-\w)/g, (match) => match.replace("-", "").toUpperCase());
|
|
668
|
+
}
|
|
669
|
+
function relativeImportPath(from, to) {
|
|
670
|
+
let rel = path.relative(from, to).replace(/\\/g, "/");
|
|
671
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
672
|
+
return rel.replace(/\.(ts|mts|js|mjs)$/, "");
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/build/build-orchestrator.ts
|
|
676
|
+
var BuildOrchestrator = class {
|
|
677
|
+
/**
|
|
678
|
+
* Scans the source tree for buildable files.
|
|
679
|
+
*/
|
|
680
|
+
scanner = new FileScanner();
|
|
681
|
+
/**
|
|
682
|
+
* Generates the route manifest consumed at runtime.
|
|
683
|
+
*/
|
|
684
|
+
routeGenerator = new RouteManifestGenerator();
|
|
685
|
+
/**
|
|
686
|
+
* Generates the event manifest consumed at runtime.
|
|
687
|
+
*/
|
|
688
|
+
eventGenerator = new EventManifestGenerator();
|
|
689
|
+
/**
|
|
690
|
+
* Generates the task manifest consumed at runtime.
|
|
691
|
+
*/
|
|
692
|
+
taskGenerator = new TaskManifestGenerator();
|
|
693
|
+
/**
|
|
694
|
+
* Runs the full build pipeline for a Lithia application.
|
|
695
|
+
*
|
|
696
|
+
* The build flow removes the previous output directory, scans source files,
|
|
697
|
+
* compiles them into the output tree, generates runtime manifests, emits
|
|
698
|
+
* optional OpenAPI artifacts, and writes generated types for discovered
|
|
699
|
+
* tasks.
|
|
700
|
+
*
|
|
701
|
+
* @param {BuildConfig} config - Build inputs that define the source root,
|
|
702
|
+
* output root, and optional OpenAPI generation settings.
|
|
703
|
+
* @returns {Promise<void>} Resolves after every build artifact has been
|
|
704
|
+
* generated.
|
|
705
|
+
* @throws {Error} Throws when no source files are found or when any build
|
|
706
|
+
* step fails.
|
|
707
|
+
*/
|
|
708
|
+
async build(config) {
|
|
709
|
+
await rm(config.outRoot, { recursive: true, force: true });
|
|
710
|
+
const allFiles = await this.scanner.scanDir([config.sourceDir], {
|
|
711
|
+
include: ["**/*.{ts,js,mts,mjs}"],
|
|
712
|
+
ignore: ["**/node_modules/**", "**/*.{test|spec}.ts", "**/.*", "dist/**"]
|
|
713
|
+
});
|
|
714
|
+
if (allFiles.length === 0) {
|
|
715
|
+
throw new Error(`No source files found in ${config.sourceDir}`);
|
|
716
|
+
}
|
|
717
|
+
await compileSourceFiles(allFiles, config);
|
|
718
|
+
const distFiles = allFiles.map((file) => ({
|
|
719
|
+
...file,
|
|
720
|
+
fullPath: path.join(
|
|
721
|
+
process.cwd(),
|
|
722
|
+
toOutputFilePath(config.outRoot, file.path)
|
|
723
|
+
)
|
|
724
|
+
}));
|
|
725
|
+
const [routesManifest, , tasks] = await Promise.all([
|
|
726
|
+
this.routeGenerator.generateManifest(config.outRoot, distFiles),
|
|
727
|
+
this.eventGenerator.generateManifest(config.outRoot, distFiles),
|
|
728
|
+
this.taskGenerator.generateManifest(config.outRoot, distFiles)
|
|
729
|
+
]);
|
|
730
|
+
await this.generateOpenAPIArtifactsIfEnabled(config, routesManifest.routes);
|
|
731
|
+
const registry = this.createRegistry(allFiles, tasks?.tasks || []);
|
|
732
|
+
if (Object.keys(registry).length > 0) {
|
|
733
|
+
await generateLithiaTypes(process.cwd(), registry);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Creates the type generation registry for discovered async tasks.
|
|
738
|
+
*
|
|
739
|
+
* The registry maps runtime task identifiers back to source file paths so
|
|
740
|
+
* generated types reference the original task modules instead of compiled
|
|
741
|
+
* output files. Task conventions are described in
|
|
742
|
+
* [Async Tasks](https://lithiajs.org/docs/latest/async-tasks).
|
|
743
|
+
*
|
|
744
|
+
* @param {{ path: string; fullPath: string }[]} allFiles - Source files
|
|
745
|
+
* scanned before compilation.
|
|
746
|
+
* @param {TaskCore[]} tasks - Runtime task manifest entries generated from
|
|
747
|
+
* compiled files.
|
|
748
|
+
* @returns {GeneratorRegistry} Type generation metadata keyed by task
|
|
749
|
+
* identifier, or an empty registry when no tasks are present.
|
|
750
|
+
*/
|
|
751
|
+
createRegistry(allFiles, tasks) {
|
|
752
|
+
if (tasks.length === 0) return {};
|
|
753
|
+
const sourceTaskFiles = allFiles.filter((file) => {
|
|
754
|
+
const normalized = file.path.split(path.sep).join("/");
|
|
755
|
+
return normalized.includes("tasks/") || normalized.includes("app/tasks/");
|
|
756
|
+
});
|
|
757
|
+
const sourceTaskPathById = new Map(
|
|
758
|
+
sourceTaskFiles.map((file) => [
|
|
759
|
+
this.resolveTaskIdentifier(file.path),
|
|
760
|
+
file.fullPath
|
|
761
|
+
])
|
|
762
|
+
);
|
|
763
|
+
return {
|
|
764
|
+
tasks: tasks.map((task) => ({
|
|
765
|
+
identifier: task.id,
|
|
766
|
+
filePath: sourceTaskPathById.get(task.id) || task.filePath
|
|
767
|
+
}))
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Converts a task source path into the runtime task identifier format.
|
|
772
|
+
*
|
|
773
|
+
* The normalization removes the task root, file extension, optional `.cron`
|
|
774
|
+
* suffix, and grouping segments, then joins remaining path segments with
|
|
775
|
+
* colons.
|
|
776
|
+
*
|
|
777
|
+
* @param {string} filePath - Task source path relative to the scanned source
|
|
778
|
+
* tree.
|
|
779
|
+
* @returns {string} Runtime task identifier derived from the file path.
|
|
780
|
+
*/
|
|
781
|
+
resolveTaskIdentifier(filePath) {
|
|
782
|
+
const normalized = filePath.replace(/\\/g, "/").replace(/^(app\/)?tasks\//, "").replace(/^(.*?)(?:\.(cron))?\.(mts|mjs|ts|js)$/i, "$1").replace(/\(([^([/]+)\)\//g, "");
|
|
783
|
+
return normalized.split("/").filter((part) => part.length > 0).join(":");
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Generates OpenAPI artifacts when the build enables OpenAPI output.
|
|
787
|
+
*
|
|
788
|
+
* Before generating artifacts, this method validates that reserved docs and
|
|
789
|
+
* spec routes do not collide with discovered GET routes.
|
|
790
|
+
*
|
|
791
|
+
* @param {BuildConfig} config - Build settings containing OpenAPI options.
|
|
792
|
+
* @param {Route[]} routes - Discovered route manifest entries used to build
|
|
793
|
+
* OpenAPI output.
|
|
794
|
+
* @returns {Promise<void>} Resolves after OpenAPI artifacts are generated or
|
|
795
|
+
* skipped.
|
|
796
|
+
* @throws {Error} Throws when reserved OpenAPI routes are unsafe or when the
|
|
797
|
+
* OpenAPI integration cannot be loaded.
|
|
798
|
+
*/
|
|
799
|
+
async generateOpenAPIArtifactsIfEnabled(config, routes) {
|
|
800
|
+
if (!config.openapi?.enabled) return;
|
|
801
|
+
this.assertOpenAPIPathsAreSafe(routes, config.openapi);
|
|
802
|
+
const integration = await this.loadOpenAPIIntegration();
|
|
803
|
+
await integration.generateOpenAPIArtifacts({
|
|
804
|
+
outDir: config.outRoot,
|
|
805
|
+
routes,
|
|
806
|
+
config: config.openapi
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Verifies that reserved OpenAPI docs and spec paths do not conflict with
|
|
811
|
+
* discovered GET routes.
|
|
812
|
+
*
|
|
813
|
+
* Lithia serves generated docs and spec assets from reserved routes when
|
|
814
|
+
* OpenAPI is enabled, so user-defined GET routes cannot reuse those paths.
|
|
815
|
+
*
|
|
816
|
+
* @param {Route[]} routes - Discovered route entries to validate.
|
|
817
|
+
* @param {OpenAPIConfig} config - OpenAPI settings that define reserved
|
|
818
|
+
* paths.
|
|
819
|
+
* @throws {Error} Throws when `docsPath` and `specPath` match or when a GET
|
|
820
|
+
* route conflicts with either reserved path.
|
|
821
|
+
*/
|
|
822
|
+
assertOpenAPIPathsAreSafe(routes, config) {
|
|
823
|
+
const docsPath = normalizeReservedPath(config.docsPath);
|
|
824
|
+
const specPath = normalizeReservedPath(config.specPath);
|
|
825
|
+
if (docsPath === specPath) {
|
|
826
|
+
throw new Error(
|
|
827
|
+
"OpenAPI configuration error: docsPath and specPath must be different."
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
for (const route of routes) {
|
|
831
|
+
if (route.method && route.method.toUpperCase() !== "GET") continue;
|
|
832
|
+
const routePath = normalizeReservedPath(route.path);
|
|
833
|
+
if (routePath === docsPath || routePath === specPath) {
|
|
834
|
+
throw new Error(
|
|
835
|
+
`OpenAPI route conflict: '${route.path}' conflicts with reserved docs/spec route.`
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Loads the optional `@lithia-js/openapi` integration from the current
|
|
842
|
+
* project.
|
|
843
|
+
*
|
|
844
|
+
* Resolution happens from the consumer project so the build uses the
|
|
845
|
+
* project's installed package instead of assuming the integration is
|
|
846
|
+
* available in the core package environment.
|
|
847
|
+
*
|
|
848
|
+
* @returns {Promise<{ generateOpenAPIArtifacts: (options: { outDir: string; routes: Route[]; config: OpenAPIConfig; }) => Promise<void>; }>}
|
|
849
|
+
* Module interface used to emit OpenAPI build artifacts.
|
|
850
|
+
* @throws {Error} Throws when OpenAPI is enabled but the integration package
|
|
851
|
+
* is missing or fails to load.
|
|
852
|
+
*/
|
|
853
|
+
async loadOpenAPIIntegration() {
|
|
854
|
+
try {
|
|
855
|
+
const requireFromProject = createRequire(
|
|
856
|
+
path.join(process.cwd(), "package.json")
|
|
857
|
+
);
|
|
858
|
+
const resolvedPath = requireFromProject.resolve("@lithia-js/openapi");
|
|
859
|
+
return await import(pathToFileURL(resolvedPath).href);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
throw new Error(
|
|
862
|
+
"OpenAPI is enabled, but '@lithia-js/openapi' is not installed or could not be loaded.",
|
|
863
|
+
{ cause: error }
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
function normalizeReservedPath(pathname) {
|
|
869
|
+
if (!pathname) return "/";
|
|
870
|
+
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
871
|
+
if (normalized.length > 1 && normalized.endsWith("/")) {
|
|
872
|
+
return normalized.slice(0, -1);
|
|
873
|
+
}
|
|
874
|
+
return normalized;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// src/config.ts
|
|
878
|
+
var DEFAULT_CONFIG = {
|
|
879
|
+
sourceDir: "src",
|
|
880
|
+
outDir: "dist",
|
|
881
|
+
envFiles: [".env", ".env.local", ".env.development"],
|
|
882
|
+
http: {
|
|
883
|
+
port: 3e3,
|
|
884
|
+
host: "localhost",
|
|
885
|
+
maxBodySize: 1024 * 1024,
|
|
886
|
+
cors: {
|
|
887
|
+
origin: ["*"],
|
|
888
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
889
|
+
allowedHeaders: ["Content-Type", "Authorization"],
|
|
890
|
+
exposedHeaders: ["X-Powered-By"],
|
|
891
|
+
credentials: false,
|
|
892
|
+
maxAge: 86400
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
logging: {
|
|
896
|
+
requests: true,
|
|
897
|
+
events: true,
|
|
898
|
+
tasks: true
|
|
899
|
+
},
|
|
900
|
+
asyncTasks: {
|
|
901
|
+
concurrencyLimit: 10,
|
|
902
|
+
timeoutMs: 3e4
|
|
903
|
+
},
|
|
904
|
+
openapi: {
|
|
905
|
+
enabled: false,
|
|
906
|
+
docsPath: "/docs",
|
|
907
|
+
specPath: "/openapi.json",
|
|
908
|
+
title: "Lithia API",
|
|
909
|
+
version: "1.0.0"
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
// src/config/load-config.ts
|
|
914
|
+
async function loadConfig() {
|
|
915
|
+
const configOptions = {
|
|
916
|
+
name: "lithia",
|
|
917
|
+
configFile: "lithia.config",
|
|
918
|
+
cwd: process.cwd(),
|
|
919
|
+
dotenv: true,
|
|
920
|
+
defaults: DEFAULT_CONFIG
|
|
921
|
+
};
|
|
922
|
+
const { config } = await loadConfig$1(configOptions);
|
|
923
|
+
return klona(config);
|
|
924
|
+
}
|
|
925
|
+
var AppSupervisor = class {
|
|
926
|
+
/**
|
|
927
|
+
* Creates a supervisor for the app worker lifecycle.
|
|
928
|
+
*
|
|
929
|
+
* @param {() => CreateWorkerOptions} createOptions - Factory that returns
|
|
930
|
+
* the current worker payload and environment for each spawn.
|
|
931
|
+
* @param {(event: AppToHostEvent) => Promise<void>} onInvoke - Callback that
|
|
932
|
+
* handles invocation messages forwarded from the app worker to the host.
|
|
933
|
+
* @param {string} workerBaseDir - Base directory containing the published
|
|
934
|
+
* worker entrypoints.
|
|
935
|
+
*/
|
|
936
|
+
constructor(createOptions, onInvoke, workerBaseDir) {
|
|
937
|
+
this.createOptions = createOptions;
|
|
938
|
+
this.onInvoke = onInvoke;
|
|
939
|
+
this.workerBaseDir = workerBaseDir;
|
|
940
|
+
}
|
|
941
|
+
_worker = null;
|
|
942
|
+
_isReady = false;
|
|
943
|
+
_isRunning = false;
|
|
944
|
+
/**
|
|
945
|
+
* Returns the currently supervised app worker instance.
|
|
946
|
+
*/
|
|
947
|
+
get worker() {
|
|
948
|
+
return this._worker;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Returns whether the current worker has reported readiness.
|
|
952
|
+
*/
|
|
953
|
+
get isReady() {
|
|
954
|
+
return this._isReady;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Returns whether the supervisor currently considers the worker running.
|
|
958
|
+
*/
|
|
959
|
+
get isRunning() {
|
|
960
|
+
return this._isRunning;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Starts the app worker and waits for it to report readiness.
|
|
964
|
+
*
|
|
965
|
+
* @returns {Promise<void>} Resolves after the worker emits a `ready` event.
|
|
966
|
+
* @throws {Error} Throws when worker startup fails or the worker exits before
|
|
967
|
+
* becoming ready.
|
|
968
|
+
*/
|
|
969
|
+
async start() {
|
|
970
|
+
await this.spawnWorker();
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Replaces the current worker with a fresh one.
|
|
974
|
+
*
|
|
975
|
+
* If a worker is already running, it is terminated before the replacement
|
|
976
|
+
* worker is spawned.
|
|
977
|
+
*
|
|
978
|
+
* @returns {Promise<void>} Resolves after the replacement worker reports
|
|
979
|
+
* readiness.
|
|
980
|
+
* @throws {Error} Throws when the replacement worker fails during startup.
|
|
981
|
+
*/
|
|
982
|
+
async swap() {
|
|
983
|
+
if (this._worker && this._isRunning) {
|
|
984
|
+
await this.dispose();
|
|
985
|
+
}
|
|
986
|
+
await this.spawnWorker();
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Terminates the current worker and resets supervisor state.
|
|
990
|
+
*
|
|
991
|
+
* @returns {Promise<void>} Resolves after the current worker has been
|
|
992
|
+
* terminated, or immediately when no worker is present.
|
|
993
|
+
*/
|
|
994
|
+
async dispose() {
|
|
995
|
+
if (!this._worker) return;
|
|
996
|
+
const worker = this._worker;
|
|
997
|
+
this._worker = null;
|
|
998
|
+
this._isRunning = false;
|
|
999
|
+
this._isReady = false;
|
|
1000
|
+
await worker.terminate();
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Spawns the app worker and waits for either `ready` or a startup failure.
|
|
1004
|
+
*
|
|
1005
|
+
* The supervisor listens for worker lifecycle events, updates readiness and
|
|
1006
|
+
* running state, forwards invocation messages to the host callback, and
|
|
1007
|
+
* rejects startup when the worker reports an error or exits before becoming
|
|
1008
|
+
* ready.
|
|
1009
|
+
*
|
|
1010
|
+
* @returns {Promise<void>} Resolves after the worker becomes ready.
|
|
1011
|
+
* @throws {Error} Throws when worker construction, startup, or early exit
|
|
1012
|
+
* fails.
|
|
1013
|
+
*/
|
|
1014
|
+
async spawnWorker() {
|
|
1015
|
+
logger.debug("Spawning background worker...");
|
|
1016
|
+
const options = this.createOptions();
|
|
1017
|
+
const worker = new Worker(
|
|
1018
|
+
path.join(this.workerBaseDir, "workers", "app-worker.mjs"),
|
|
1019
|
+
{
|
|
1020
|
+
workerData: options.workerData,
|
|
1021
|
+
env: options.env
|
|
1022
|
+
}
|
|
1023
|
+
);
|
|
1024
|
+
this._worker = worker;
|
|
1025
|
+
this._isReady = false;
|
|
1026
|
+
this._isRunning = false;
|
|
1027
|
+
await new Promise((resolve, reject) => {
|
|
1028
|
+
let isSettled = false;
|
|
1029
|
+
const resolveIfPending = () => {
|
|
1030
|
+
if (isSettled) return;
|
|
1031
|
+
isSettled = true;
|
|
1032
|
+
resolve();
|
|
1033
|
+
};
|
|
1034
|
+
const rejectIfPending = (error) => {
|
|
1035
|
+
if (isSettled) return;
|
|
1036
|
+
isSettled = true;
|
|
1037
|
+
reject(error);
|
|
1038
|
+
};
|
|
1039
|
+
worker.on("message", async (message) => {
|
|
1040
|
+
if (message.type === "ready") {
|
|
1041
|
+
this._isReady = true;
|
|
1042
|
+
this._isRunning = true;
|
|
1043
|
+
resolveIfPending();
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (message.type === "error") {
|
|
1047
|
+
logger.error("Lithia app worker reported an error:", message.error);
|
|
1048
|
+
const messageText = typeof message.error === "object" && message.error !== null && "message" in message.error ? String(message.error.message) : "Lithia app worker reported an unknown startup error.";
|
|
1049
|
+
rejectIfPending(new Error(messageText));
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
await this.onInvoke(message);
|
|
1053
|
+
});
|
|
1054
|
+
worker.on("error", (error) => {
|
|
1055
|
+
logger.error("Worker Thread crashed:", error);
|
|
1056
|
+
this._isRunning = false;
|
|
1057
|
+
this._isReady = false;
|
|
1058
|
+
if (this._worker === worker) {
|
|
1059
|
+
this._worker = null;
|
|
1060
|
+
}
|
|
1061
|
+
rejectIfPending(
|
|
1062
|
+
error instanceof Error ? error : new Error(String(error))
|
|
1063
|
+
);
|
|
1064
|
+
});
|
|
1065
|
+
worker.on("exit", (code) => {
|
|
1066
|
+
this._isRunning = false;
|
|
1067
|
+
this._isReady = false;
|
|
1068
|
+
if (this._worker === worker) {
|
|
1069
|
+
this._worker = null;
|
|
1070
|
+
}
|
|
1071
|
+
if (code !== 0) {
|
|
1072
|
+
logger.debug(`App worker exited with code ${code}`);
|
|
1073
|
+
}
|
|
1074
|
+
rejectIfPending(
|
|
1075
|
+
new Error(`App worker exited before becoming ready (code ${code}).`)
|
|
1076
|
+
);
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
var ManifestStore = class {
|
|
1082
|
+
/**
|
|
1083
|
+
* Creates a manifest store backed by the current resolved host config.
|
|
1084
|
+
*
|
|
1085
|
+
* @param {() => LithiaOptions} getConfig - Accessor that returns the current
|
|
1086
|
+
* resolved host config, including `outDir`.
|
|
1087
|
+
*/
|
|
1088
|
+
constructor(getConfig) {
|
|
1089
|
+
this.getConfig = getConfig;
|
|
1090
|
+
}
|
|
1091
|
+
_routes = [];
|
|
1092
|
+
_events = [];
|
|
1093
|
+
_tasks = [];
|
|
1094
|
+
/**
|
|
1095
|
+
* Returns the cached route manifest entries.
|
|
1096
|
+
*/
|
|
1097
|
+
get routes() {
|
|
1098
|
+
return this._routes;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Returns the cached event manifest entries.
|
|
1102
|
+
*/
|
|
1103
|
+
get events() {
|
|
1104
|
+
return this._events;
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Returns the cached async task manifest entries.
|
|
1108
|
+
*/
|
|
1109
|
+
get tasks() {
|
|
1110
|
+
return this._tasks;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Loads the routes manifest into memory.
|
|
1114
|
+
*
|
|
1115
|
+
* Missing manifest files leave the current cache unchanged.
|
|
1116
|
+
*
|
|
1117
|
+
* @returns {Promise<void>} Resolves after the route cache has been refreshed
|
|
1118
|
+
* when a manifest exists.
|
|
1119
|
+
*/
|
|
1120
|
+
async loadRoutes() {
|
|
1121
|
+
const manifest = await this.loadManifest("routes.json");
|
|
1122
|
+
if (manifest) this._routes = manifest.routes;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Loads the events manifest into memory.
|
|
1126
|
+
*
|
|
1127
|
+
* Missing manifest files leave the current cache unchanged.
|
|
1128
|
+
*
|
|
1129
|
+
* @returns {Promise<void>} Resolves after the event cache has been refreshed
|
|
1130
|
+
* when a manifest exists.
|
|
1131
|
+
*/
|
|
1132
|
+
async loadEvents() {
|
|
1133
|
+
const manifest = await this.loadManifest("events.json");
|
|
1134
|
+
if (manifest) this._events = manifest.events;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Loads the async tasks manifest into memory.
|
|
1138
|
+
*
|
|
1139
|
+
* Missing manifest files leave the current cache unchanged.
|
|
1140
|
+
*
|
|
1141
|
+
* @returns {Promise<void>} Resolves after the task cache has been refreshed
|
|
1142
|
+
* when a manifest exists.
|
|
1143
|
+
*/
|
|
1144
|
+
async loadTasks() {
|
|
1145
|
+
const manifest = await this.loadManifest("tasks.json");
|
|
1146
|
+
if (manifest) this._tasks = manifest.tasks;
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Loads all runtime manifests in parallel.
|
|
1150
|
+
*
|
|
1151
|
+
* @returns {Promise<void>} Resolves after route, event, and task manifests
|
|
1152
|
+
* have been refreshed.
|
|
1153
|
+
*/
|
|
1154
|
+
async loadAll() {
|
|
1155
|
+
await Promise.all([this.loadRoutes(), this.loadEvents(), this.loadTasks()]);
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Reads, parses, and validates a versioned manifest from the build output.
|
|
1159
|
+
*
|
|
1160
|
+
* @param {string} fileName - Manifest file name inside the configured output
|
|
1161
|
+
* directory.
|
|
1162
|
+
* @returns {Promise<T | null>} Parsed manifest object, or `null` when the
|
|
1163
|
+
* file does not exist.
|
|
1164
|
+
* @throws {Error} Throws when the manifest cannot be read or parsed.
|
|
1165
|
+
* @throws {ManifestVersionMismatchError} Throws when the manifest schema
|
|
1166
|
+
* version does not match the current runtime schema.
|
|
1167
|
+
*/
|
|
1168
|
+
async loadManifest(fileName) {
|
|
1169
|
+
const manifestPath = path.join(
|
|
1170
|
+
process.cwd(),
|
|
1171
|
+
this.getConfig().outDir,
|
|
1172
|
+
fileName
|
|
1173
|
+
);
|
|
1174
|
+
if (!await fileExists(manifestPath)) return null;
|
|
1175
|
+
const raw = await readFile(manifestPath, "utf-8").catch((error) => {
|
|
1176
|
+
throw new Error(
|
|
1177
|
+
`Failed to read manifest '${fileName}' from '${manifestPath}': ${error.message}`
|
|
1178
|
+
);
|
|
1179
|
+
});
|
|
1180
|
+
let manifest;
|
|
1181
|
+
try {
|
|
1182
|
+
manifest = JSON.parse(raw);
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
throw new Error(
|
|
1185
|
+
`Failed to parse manifest '${fileName}' from '${manifestPath}': ${error.message}`
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
if (manifest.version !== version) {
|
|
1189
|
+
throw new ManifestVersionMismatchError(version, manifest.version);
|
|
1190
|
+
}
|
|
1191
|
+
return manifest;
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
// src/runtime/host/protocol.ts
|
|
1196
|
+
var CFG_GLOBAL_KEY = "__lithia_host_config_v1";
|
|
1197
|
+
var AsyncTaskRunner = class {
|
|
1198
|
+
/**
|
|
1199
|
+
* Creates a host-side task runner bound to the current supervisor callbacks.
|
|
1200
|
+
*
|
|
1201
|
+
* The runner reads config, manifests, and worker references lazily from the
|
|
1202
|
+
* supplied accessors so the same instance can keep working across manifest
|
|
1203
|
+
* reloads and app worker swaps.
|
|
1204
|
+
*
|
|
1205
|
+
* @param {TaskRunnerOptions} options - Deferred accessors and path metadata
|
|
1206
|
+
* used to resolve workers, manifests, runtime config, and worker
|
|
1207
|
+
* environment variables.
|
|
1208
|
+
*/
|
|
1209
|
+
constructor(options) {
|
|
1210
|
+
this.options = options;
|
|
1211
|
+
}
|
|
1212
|
+
runningTasks = 0;
|
|
1213
|
+
invocationQueue = [];
|
|
1214
|
+
syncWorkers = [];
|
|
1215
|
+
/**
|
|
1216
|
+
* Handles a task invocation coming from the app worker.
|
|
1217
|
+
*
|
|
1218
|
+
* The method enforces the global async-task concurrency limit before
|
|
1219
|
+
* resolving the task manifest entry. Awaited invocations are routed through
|
|
1220
|
+
* the warm worker pool so the host can post a correlated response back to the
|
|
1221
|
+
* app worker, while fire-and-forget invocations always spawn a dedicated
|
|
1222
|
+
* worker that owns a single execution.
|
|
1223
|
+
*
|
|
1224
|
+
* When the concurrency limit is already saturated, the invocation is pushed
|
|
1225
|
+
* into the in-memory queue and retried only after another execution calls
|
|
1226
|
+
* `finalizeInvocation()`.
|
|
1227
|
+
*
|
|
1228
|
+
* @param {TaskInvokeEvent} event - Invocation payload received from the app
|
|
1229
|
+
* worker, including task identity, execution metadata, source, and serialized
|
|
1230
|
+
* arguments.
|
|
1231
|
+
* @returns {Promise<void>} Resolves after the invocation is dispatched or, if
|
|
1232
|
+
* it had to wait for capacity, after the queued invocation has been retried.
|
|
1233
|
+
*/
|
|
1234
|
+
async handleInvocation(event) {
|
|
1235
|
+
const limit = this.options.getConfig().asyncTasks.concurrencyLimit;
|
|
1236
|
+
if (this.runningTasks >= limit) {
|
|
1237
|
+
logger.debug(
|
|
1238
|
+
`[task:${event.taskId}] Concurrency limit reached, queuing invocation...`
|
|
1239
|
+
);
|
|
1240
|
+
return new Promise((resolve) => {
|
|
1241
|
+
this.invocationQueue.push(async () => {
|
|
1242
|
+
await this.handleInvocation(event);
|
|
1243
|
+
resolve();
|
|
1244
|
+
});
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
this.runningTasks++;
|
|
1248
|
+
const taskMeta = this.options.getTasks().find((candidate) => candidate.id === event.taskId);
|
|
1249
|
+
if (!taskMeta) {
|
|
1250
|
+
if (!event.async) {
|
|
1251
|
+
this.postSyncError(
|
|
1252
|
+
event,
|
|
1253
|
+
this.createErrorPayload(
|
|
1254
|
+
"TaskNotFoundError",
|
|
1255
|
+
`[task:${event.taskId}] Task not found in manifest.`
|
|
1256
|
+
)
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
this.logTaskResult(
|
|
1260
|
+
event,
|
|
1261
|
+
event.taskId,
|
|
1262
|
+
"error",
|
|
1263
|
+
0,
|
|
1264
|
+
"Task not found in manifest."
|
|
1265
|
+
);
|
|
1266
|
+
this.finalizeInvocation();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const startedAt = performance.now();
|
|
1270
|
+
if (event.async) {
|
|
1271
|
+
this.executeWithDedicatedWorker(event, taskMeta, startedAt);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
this.executeWithWarmWorker(event, taskMeta, startedAt);
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Terminates all warm workers and clears the reusable pool.
|
|
1278
|
+
*
|
|
1279
|
+
* This is used during host reset or shutdown to guarantee that no pooled
|
|
1280
|
+
* awaited-task worker survives across runtime swaps.
|
|
1281
|
+
*
|
|
1282
|
+
* @returns {Promise<void>} Resolves after every currently tracked warm worker
|
|
1283
|
+
* has been asked to terminate.
|
|
1284
|
+
*/
|
|
1285
|
+
async reset() {
|
|
1286
|
+
const workers = this.syncWorkers.splice(0);
|
|
1287
|
+
await Promise.allSettled(workers.map((slot) => slot.worker.terminate()));
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Starts a fire-and-forget invocation in its own dedicated worker.
|
|
1291
|
+
*
|
|
1292
|
+
* This path is used for dispatch-style task execution where the caller does
|
|
1293
|
+
* not await a result. A fresh worker is created for the invocation and its
|
|
1294
|
+
* full lifecycle is delegated to `attachDedicatedWorkerLifecycle()`.
|
|
1295
|
+
*
|
|
1296
|
+
* @param {AppInvokeAsyncEvent} event - Async dispatch metadata emitted by the
|
|
1297
|
+
* app worker.
|
|
1298
|
+
* @param {TaskCore} taskMeta - Manifest entry for the target task.
|
|
1299
|
+
* @param {number} startedAt - High-resolution timestamp captured before
|
|
1300
|
+
* dispatch begins.
|
|
1301
|
+
*/
|
|
1302
|
+
executeWithDedicatedWorker(event, taskMeta, startedAt) {
|
|
1303
|
+
const worker = this.createDedicatedWorker(taskMeta, event.args || []);
|
|
1304
|
+
this.attachDedicatedWorkerLifecycle(worker, event, taskMeta, startedAt);
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Executes an awaited invocation through the warm worker pool.
|
|
1308
|
+
*
|
|
1309
|
+
* Awaited tasks keep their worker alive for reuse after a successful
|
|
1310
|
+
* invocation, but the worker is discarded if it times out, throws at the
|
|
1311
|
+
* thread level, or exits unexpectedly. The host removes all listeners during
|
|
1312
|
+
* cleanup and posts either a success or error response back to the app worker
|
|
1313
|
+
* using the original `requestId`.
|
|
1314
|
+
*
|
|
1315
|
+
* @param {AppInvokeSyncEvent} event - Awaited invocation metadata emitted by
|
|
1316
|
+
* the app worker.
|
|
1317
|
+
* @param {TaskCore} taskMeta - Manifest entry for the target task.
|
|
1318
|
+
* @param {number} startedAt - High-resolution timestamp captured before the
|
|
1319
|
+
* worker receives the invocation.
|
|
1320
|
+
*/
|
|
1321
|
+
executeWithWarmWorker(event, taskMeta, startedAt) {
|
|
1322
|
+
const slot = this.acquireSyncWorker();
|
|
1323
|
+
slot.busy = true;
|
|
1324
|
+
const timeoutMs = this.options.getConfig().asyncTasks.timeoutMs;
|
|
1325
|
+
let finalized = false;
|
|
1326
|
+
const cleanup = () => {
|
|
1327
|
+
if (finalized) return;
|
|
1328
|
+
finalized = true;
|
|
1329
|
+
clearTimeout(timer);
|
|
1330
|
+
slot.busy = false;
|
|
1331
|
+
slot.worker.off("message", onMessage);
|
|
1332
|
+
slot.worker.off("error", onError);
|
|
1333
|
+
slot.worker.off("exit", onExit);
|
|
1334
|
+
this.finalizeInvocation();
|
|
1335
|
+
};
|
|
1336
|
+
const fail = (error, errorMessage, removeWorker = false) => {
|
|
1337
|
+
if (finalized) return;
|
|
1338
|
+
this.postSyncError(event, error);
|
|
1339
|
+
this.logTaskResult(
|
|
1340
|
+
event,
|
|
1341
|
+
taskMeta.id,
|
|
1342
|
+
"error",
|
|
1343
|
+
performance.now() - startedAt,
|
|
1344
|
+
errorMessage
|
|
1345
|
+
);
|
|
1346
|
+
if (removeWorker) {
|
|
1347
|
+
void slot.worker.terminate();
|
|
1348
|
+
this.removeSyncWorker(slot);
|
|
1349
|
+
}
|
|
1350
|
+
cleanup();
|
|
1351
|
+
};
|
|
1352
|
+
const timer = setTimeout(() => {
|
|
1353
|
+
fail(
|
|
1354
|
+
this.createErrorPayload(
|
|
1355
|
+
"TaskTimeoutError",
|
|
1356
|
+
`[task:${taskMeta.id}] Task timed out after ${timeoutMs}ms.`
|
|
1357
|
+
),
|
|
1358
|
+
`Timed out after ${timeoutMs}ms.`,
|
|
1359
|
+
true
|
|
1360
|
+
);
|
|
1361
|
+
}, timeoutMs);
|
|
1362
|
+
const onMessage = (message) => {
|
|
1363
|
+
if (finalized || message.executionId !== event.executionId) return;
|
|
1364
|
+
if (message.type === "error") {
|
|
1365
|
+
fail(message.error, message.error.message);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
this.postSyncSuccess(event, message.result);
|
|
1369
|
+
this.logTaskResult(
|
|
1370
|
+
event,
|
|
1371
|
+
taskMeta.id,
|
|
1372
|
+
"success",
|
|
1373
|
+
performance.now() - startedAt
|
|
1374
|
+
);
|
|
1375
|
+
cleanup();
|
|
1376
|
+
};
|
|
1377
|
+
const onError = (error) => {
|
|
1378
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1379
|
+
fail(
|
|
1380
|
+
this.createErrorPayload("TaskExecutionError", message),
|
|
1381
|
+
message,
|
|
1382
|
+
true
|
|
1383
|
+
);
|
|
1384
|
+
};
|
|
1385
|
+
const onExit = (code) => {
|
|
1386
|
+
if (finalized) return;
|
|
1387
|
+
fail(
|
|
1388
|
+
this.createErrorPayload(
|
|
1389
|
+
"TaskExecutionError",
|
|
1390
|
+
`[task:${taskMeta.id}] Worker exited with code ${code}.`
|
|
1391
|
+
),
|
|
1392
|
+
`Exited with code ${code}.`,
|
|
1393
|
+
true
|
|
1394
|
+
);
|
|
1395
|
+
};
|
|
1396
|
+
slot.worker.on("message", onMessage);
|
|
1397
|
+
slot.worker.once("error", onError);
|
|
1398
|
+
slot.worker.once("exit", onExit);
|
|
1399
|
+
slot.worker.postMessage({
|
|
1400
|
+
type: "invoke",
|
|
1401
|
+
executionId: event.executionId,
|
|
1402
|
+
task: taskMeta,
|
|
1403
|
+
args: event.args || []
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Attaches lifecycle handlers to a dedicated task worker.
|
|
1408
|
+
*
|
|
1409
|
+
* Dedicated workers are used for fire-and-forget dispatches, so the host does
|
|
1410
|
+
* not need to post a result back to the app worker. Instead, it watches for
|
|
1411
|
+
* completion, timeout, worker crashes, and non-zero exits, logs the terminal
|
|
1412
|
+
* outcome, and schedules CRON retries when the manifest allows them.
|
|
1413
|
+
*
|
|
1414
|
+
* The worker is also `unref()`ed so pending detached task executions do not
|
|
1415
|
+
* keep the host process alive on their own.
|
|
1416
|
+
*
|
|
1417
|
+
* @param {Worker} worker - Fresh one-shot worker created for a single async
|
|
1418
|
+
* dispatch.
|
|
1419
|
+
* @param {AppInvokeAsyncEvent} event - Fire-and-forget invocation metadata,
|
|
1420
|
+
* including execution source and retry attempt.
|
|
1421
|
+
* @param {TaskCore} taskMeta - Manifest entry that describes the task file and
|
|
1422
|
+
* retry policy.
|
|
1423
|
+
* @param {number} startedAt - High-resolution timestamp captured before
|
|
1424
|
+
* worker execution starts and used for final logging.
|
|
1425
|
+
*/
|
|
1426
|
+
attachDedicatedWorkerLifecycle(worker, event, taskMeta, startedAt) {
|
|
1427
|
+
const timeoutMs = this.options.getConfig().asyncTasks.timeoutMs;
|
|
1428
|
+
let isFinalized = false;
|
|
1429
|
+
let completionMessageReceived = false;
|
|
1430
|
+
const finalize = () => {
|
|
1431
|
+
if (isFinalized) return;
|
|
1432
|
+
isFinalized = true;
|
|
1433
|
+
clearTimeout(timer);
|
|
1434
|
+
logger.debug(`[task:${taskMeta.id}] Task finalized.`);
|
|
1435
|
+
this.finalizeInvocation();
|
|
1436
|
+
};
|
|
1437
|
+
const timer = setTimeout(async () => {
|
|
1438
|
+
if (isFinalized) return;
|
|
1439
|
+
const timeoutMessage = `Timed out after ${timeoutMs}ms.`;
|
|
1440
|
+
if (this.scheduleRetryIfEligible(event, taskMeta, timeoutMessage)) {
|
|
1441
|
+
await worker.terminate();
|
|
1442
|
+
finalize();
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
this.logTaskResult(
|
|
1446
|
+
event,
|
|
1447
|
+
taskMeta.id,
|
|
1448
|
+
"error",
|
|
1449
|
+
performance.now() - startedAt,
|
|
1450
|
+
timeoutMessage
|
|
1451
|
+
);
|
|
1452
|
+
await worker.terminate();
|
|
1453
|
+
finalize();
|
|
1454
|
+
}, timeoutMs);
|
|
1455
|
+
worker.on("message", (message) => {
|
|
1456
|
+
if (isFinalized) return;
|
|
1457
|
+
completionMessageReceived = true;
|
|
1458
|
+
if (message.type === "error") {
|
|
1459
|
+
if (this.scheduleRetryIfEligible(event, taskMeta, message.error.message)) {
|
|
1460
|
+
finalize();
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
this.logTaskResult(
|
|
1464
|
+
event,
|
|
1465
|
+
taskMeta.id,
|
|
1466
|
+
"error",
|
|
1467
|
+
performance.now() - startedAt,
|
|
1468
|
+
message.error.message
|
|
1469
|
+
);
|
|
1470
|
+
finalize();
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
this.logTaskResult(
|
|
1474
|
+
event,
|
|
1475
|
+
taskMeta.id,
|
|
1476
|
+
"success",
|
|
1477
|
+
performance.now() - startedAt
|
|
1478
|
+
);
|
|
1479
|
+
finalize();
|
|
1480
|
+
});
|
|
1481
|
+
worker.unref();
|
|
1482
|
+
worker.on("error", (error) => {
|
|
1483
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1484
|
+
if (this.scheduleRetryIfEligible(event, taskMeta, message)) {
|
|
1485
|
+
finalize();
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
this.logTaskResult(
|
|
1489
|
+
event,
|
|
1490
|
+
taskMeta.id,
|
|
1491
|
+
"error",
|
|
1492
|
+
performance.now() - startedAt,
|
|
1493
|
+
message
|
|
1494
|
+
);
|
|
1495
|
+
finalize();
|
|
1496
|
+
});
|
|
1497
|
+
worker.on("exit", (code) => {
|
|
1498
|
+
if (isFinalized) return;
|
|
1499
|
+
if (completionMessageReceived) {
|
|
1500
|
+
finalize();
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (code !== 0) {
|
|
1504
|
+
const exitMessage = `Exited with code ${code}.`;
|
|
1505
|
+
if (this.scheduleRetryIfEligible(event, taskMeta, exitMessage)) {
|
|
1506
|
+
finalize();
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
this.logTaskResult(
|
|
1510
|
+
event,
|
|
1511
|
+
taskMeta.id,
|
|
1512
|
+
"error",
|
|
1513
|
+
performance.now() - startedAt,
|
|
1514
|
+
exitMessage
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
finalize();
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Creates a new dedicated worker for a fire-and-forget task execution.
|
|
1522
|
+
*
|
|
1523
|
+
* Dedicated workers receive the task manifest entry and arguments through
|
|
1524
|
+
* `workerData`, execute exactly one task, and then exit. This isolates async
|
|
1525
|
+
* dispatches from pooled awaited-task workers and avoids cross-invocation
|
|
1526
|
+
* listener management.
|
|
1527
|
+
*
|
|
1528
|
+
* @param {TaskCore} taskMeta - Manifest entry describing the task module that
|
|
1529
|
+
* should run inside the worker.
|
|
1530
|
+
* @param {unknown[]} args - Serialized invocation arguments forwarded to the
|
|
1531
|
+
* worker entrypoint.
|
|
1532
|
+
* @returns {Worker} A new worker thread configured for one-shot task
|
|
1533
|
+
* execution.
|
|
1534
|
+
*/
|
|
1535
|
+
createDedicatedWorker(taskMeta, args) {
|
|
1536
|
+
return new Worker(
|
|
1537
|
+
path.join(this.options.workerBaseDir, "workers", "task-worker.mjs"),
|
|
1538
|
+
{
|
|
1539
|
+
workerData: {
|
|
1540
|
+
managedBy: "lithia",
|
|
1541
|
+
environment: this.options.getEnvironment(),
|
|
1542
|
+
config: this.options.getConfig(),
|
|
1543
|
+
task: taskMeta,
|
|
1544
|
+
args
|
|
1545
|
+
},
|
|
1546
|
+
env: { FORCE_COLOR: "1", ...this.options.getEnv() }
|
|
1547
|
+
}
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Returns an idle warm worker or creates a new one when needed.
|
|
1552
|
+
*
|
|
1553
|
+
* Warm workers stay alive across awaited invocations and receive work over
|
|
1554
|
+
* `postMessage()`. The pool grows on demand and never shrinks automatically;
|
|
1555
|
+
* failed workers are removed explicitly through `removeSyncWorker()`.
|
|
1556
|
+
*
|
|
1557
|
+
* @returns {SyncWorkerSlot} An idle slot ready to process an awaited
|
|
1558
|
+
* invocation.
|
|
1559
|
+
*/
|
|
1560
|
+
acquireSyncWorker() {
|
|
1561
|
+
const idleWorker = this.syncWorkers.find((slot2) => !slot2.busy);
|
|
1562
|
+
if (idleWorker) return idleWorker;
|
|
1563
|
+
const slot = {
|
|
1564
|
+
worker: new Worker(
|
|
1565
|
+
path.join(this.options.workerBaseDir, "workers", "task-worker.mjs"),
|
|
1566
|
+
{
|
|
1567
|
+
workerData: {
|
|
1568
|
+
managedBy: "lithia",
|
|
1569
|
+
pooled: true,
|
|
1570
|
+
environment: this.options.getEnvironment(),
|
|
1571
|
+
config: this.options.getConfig()
|
|
1572
|
+
},
|
|
1573
|
+
env: { FORCE_COLOR: "1", ...this.options.getEnv() }
|
|
1574
|
+
}
|
|
1575
|
+
),
|
|
1576
|
+
busy: false
|
|
1577
|
+
};
|
|
1578
|
+
this.syncWorkers.push(slot);
|
|
1579
|
+
return slot;
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Removes a warm worker slot from the internal pool.
|
|
1583
|
+
*
|
|
1584
|
+
* This is used after fatal warm-worker failures such as timeouts, thread
|
|
1585
|
+
* errors, or unexpected exits so future awaited invocations never reuse a
|
|
1586
|
+
* broken worker instance.
|
|
1587
|
+
*
|
|
1588
|
+
* @param {SyncWorkerSlot} slot - Pool entry that should no longer be reused.
|
|
1589
|
+
*/
|
|
1590
|
+
removeSyncWorker(slot) {
|
|
1591
|
+
const index = this.syncWorkers.indexOf(slot);
|
|
1592
|
+
if (index >= 0) {
|
|
1593
|
+
this.syncWorkers.splice(index, 1);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Sends a successful awaited-task result back to the app worker.
|
|
1598
|
+
*
|
|
1599
|
+
* The response keeps the original `requestId` so the app worker can resolve
|
|
1600
|
+
* the pending promise created for `executeTask()`.
|
|
1601
|
+
*
|
|
1602
|
+
* @param {AppInvokeSyncEvent} event - Awaited invocation metadata associated
|
|
1603
|
+
* with the pending caller.
|
|
1604
|
+
* @param {unknown} result - Serializable task return value produced by the
|
|
1605
|
+
* worker.
|
|
1606
|
+
*/
|
|
1607
|
+
postSyncSuccess(event, result) {
|
|
1608
|
+
this.options.getAppWorker()?.postMessage({
|
|
1609
|
+
type: "invoke_success",
|
|
1610
|
+
taskId: event.taskId,
|
|
1611
|
+
requestId: event.requestId,
|
|
1612
|
+
result
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Sends an awaited-task failure back to the app worker.
|
|
1617
|
+
*
|
|
1618
|
+
* The payload mirrors the protocol contract used by the app worker to reject
|
|
1619
|
+
* the pending `executeTask()` request.
|
|
1620
|
+
*
|
|
1621
|
+
* @param {AppInvokeSyncEvent} event - Awaited invocation metadata associated
|
|
1622
|
+
* with the pending caller.
|
|
1623
|
+
* @param {TaskErrorPayload} error - Serializable error payload describing the
|
|
1624
|
+
* task failure.
|
|
1625
|
+
*/
|
|
1626
|
+
postSyncError(event, error) {
|
|
1627
|
+
this.options.getAppWorker()?.postMessage({
|
|
1628
|
+
type: "invoke_error",
|
|
1629
|
+
taskId: event.taskId,
|
|
1630
|
+
requestId: event.requestId,
|
|
1631
|
+
error
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Logs the final outcome of a task execution when task logging is enabled.
|
|
1636
|
+
*
|
|
1637
|
+
* Success logs include task identity and elapsed execution time. Error logs
|
|
1638
|
+
* additionally include a shortened execution identifier so failures can be
|
|
1639
|
+
* correlated with retries and worker-protocol messages.
|
|
1640
|
+
*
|
|
1641
|
+
* @param {TaskInvokeEvent} event - Invocation metadata that produced the
|
|
1642
|
+
* terminal state.
|
|
1643
|
+
* @param {string} taskId - Stable manifest identifier of the executed task.
|
|
1644
|
+
* @param {"success" | "error"} status - Final execution status to report.
|
|
1645
|
+
* @param {number} elapsed - Measured execution time in milliseconds.
|
|
1646
|
+
* @param {string} [errorMessage] - Optional human-readable failure detail
|
|
1647
|
+
* appended to error logs.
|
|
1648
|
+
*/
|
|
1649
|
+
logTaskResult(event, taskId, status, elapsed, errorMessage) {
|
|
1650
|
+
if (!this.options.getConfig().logging.tasks) return;
|
|
1651
|
+
const statusLabel = status === "success" ? green("success") : red("error");
|
|
1652
|
+
const baseMessage = status === "success" ? `[task] ${taskId} ${statusLabel} - ${elapsed.toFixed(2)}ms` : `[task] ${taskId} ${statusLabel} - ${elapsed.toFixed(2)}ms [exec:${event.executionId.slice(0, 8)}]`;
|
|
1653
|
+
if (errorMessage) {
|
|
1654
|
+
logger.info(baseMessage, errorMessage);
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
logger.info(baseMessage);
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Enqueues a retry for eligible CRON tasks.
|
|
1661
|
+
*
|
|
1662
|
+
* Retries are host-managed and only apply to CRON-triggered invocations whose
|
|
1663
|
+
* manifest declares a retry budget. The retry is appended to the same
|
|
1664
|
+
* invocation queue used for concurrency backpressure, so it will only run
|
|
1665
|
+
* after capacity becomes available again.
|
|
1666
|
+
*
|
|
1667
|
+
* @param {TaskInvokeEvent} event - Failed invocation metadata.
|
|
1668
|
+
* @param {TaskCore} taskMeta - Task manifest entry that provides the retry
|
|
1669
|
+
* budget.
|
|
1670
|
+
* @param {string} errorMessage - Failure summary included in the retry log.
|
|
1671
|
+
* @returns {boolean} `true` when a retry was enqueued, or `false` when the
|
|
1672
|
+
* invocation is not eligible for another attempt.
|
|
1673
|
+
*/
|
|
1674
|
+
scheduleRetryIfEligible(event, taskMeta, errorMessage) {
|
|
1675
|
+
const maxRetries = taskMeta.retries ?? 0;
|
|
1676
|
+
const currentAttempt = event.attempt ?? 0;
|
|
1677
|
+
if (event.source !== "CRON" || currentAttempt >= maxRetries) {
|
|
1678
|
+
return false;
|
|
1679
|
+
}
|
|
1680
|
+
const nextAttempt = currentAttempt + 1;
|
|
1681
|
+
logger.warn(
|
|
1682
|
+
`[task:${taskMeta.id}][exec:${event.executionId.slice(0, 8)}] CRON retry ${nextAttempt}/${maxRetries}`,
|
|
1683
|
+
errorMessage
|
|
1684
|
+
);
|
|
1685
|
+
this.invocationQueue.push(async () => {
|
|
1686
|
+
await this.handleInvocation({
|
|
1687
|
+
...event,
|
|
1688
|
+
attempt: nextAttempt
|
|
1689
|
+
});
|
|
1690
|
+
});
|
|
1691
|
+
return true;
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Creates a serializable task error payload.
|
|
1695
|
+
*
|
|
1696
|
+
* The host uses this helper when it needs to synthesize failures that do not
|
|
1697
|
+
* originate inside the task worker itself, such as missing manifest entries,
|
|
1698
|
+
* host-observed timeouts, or abrupt worker exits.
|
|
1699
|
+
*
|
|
1700
|
+
* @param {string} name - Stable error name to expose through the host-worker
|
|
1701
|
+
* protocol.
|
|
1702
|
+
* @param {string} message - Human-readable failure message.
|
|
1703
|
+
* @param {unknown} [cause] - Optional original cause when a serializable value
|
|
1704
|
+
* is available.
|
|
1705
|
+
* @returns {TaskErrorPayload} Serializable error payload ready to be posted to
|
|
1706
|
+
* the app worker or written to logs.
|
|
1707
|
+
*/
|
|
1708
|
+
createErrorPayload(name, message, cause) {
|
|
1709
|
+
return {
|
|
1710
|
+
name,
|
|
1711
|
+
message,
|
|
1712
|
+
cause
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Marks a task execution as complete and drains the next queued invocation.
|
|
1717
|
+
*
|
|
1718
|
+
* Every terminal path must call this exactly once after it has released any
|
|
1719
|
+
* worker-specific resources. The method decrements the global running-task
|
|
1720
|
+
* counter and immediately starts the next queued invocation, if one exists.
|
|
1721
|
+
*/
|
|
1722
|
+
finalizeInvocation() {
|
|
1723
|
+
this.runningTasks--;
|
|
1724
|
+
const next = this.invocationQueue.shift();
|
|
1725
|
+
if (next) next();
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
// src/runtime/host/host-supervisor.ts
|
|
1730
|
+
sms.install({
|
|
1731
|
+
environment: "node",
|
|
1732
|
+
handleUncaughtExceptions: false
|
|
1733
|
+
});
|
|
1734
|
+
var HostSupervisor = class {
|
|
1735
|
+
/**
|
|
1736
|
+
* Creates the main-process supervisor for a Lithia runtime instance.
|
|
1737
|
+
*
|
|
1738
|
+
* The supervisor owns configuration loading, environment snapshots, build
|
|
1739
|
+
* orchestration, manifest loading, app worker lifecycle, and async task
|
|
1740
|
+
* execution coordination.
|
|
1741
|
+
*
|
|
1742
|
+
* @param {LithiaOpts} opts - Minimal host runtime options.
|
|
1743
|
+
* @throws {Error} Throws when instantiated outside the Node.js main thread.
|
|
1744
|
+
*/
|
|
1745
|
+
constructor(opts) {
|
|
1746
|
+
this.opts = opts;
|
|
1747
|
+
if (!isMainThread) {
|
|
1748
|
+
throw new Error(
|
|
1749
|
+
"HostSupervisor must be instantiated in the Main Thread."
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
this._builder = new BuildOrchestrator();
|
|
1753
|
+
this._manifestStore = new ManifestStore(() => this.config);
|
|
1754
|
+
this._appSupervisor = new AppSupervisor(
|
|
1755
|
+
() => ({
|
|
1756
|
+
workerData: {
|
|
1757
|
+
managedBy: "lithia",
|
|
1758
|
+
environment: this.environment,
|
|
1759
|
+
config: this.config,
|
|
1760
|
+
routes: this.routes,
|
|
1761
|
+
events: this.events,
|
|
1762
|
+
tasks: this.tasks,
|
|
1763
|
+
isFirstApp: ++this._appCount === 1 || this._lastPortUsed !== this.config.http.port
|
|
1764
|
+
},
|
|
1765
|
+
env: { FORCE_COLOR: "1", ...this._env }
|
|
1766
|
+
}),
|
|
1767
|
+
(event) => this.handleAppMessage(event),
|
|
1768
|
+
import.meta.dirname
|
|
1769
|
+
);
|
|
1770
|
+
this._taskRunner = new AsyncTaskRunner({
|
|
1771
|
+
getConfig: () => this.config,
|
|
1772
|
+
getEnvironment: () => this.environment,
|
|
1773
|
+
getTasks: () => this.tasks,
|
|
1774
|
+
getAppWorker: () => this._appSupervisor.worker,
|
|
1775
|
+
getEnv: () => this._env,
|
|
1776
|
+
workerBaseDir: import.meta.dirname
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
_config;
|
|
1780
|
+
_env = {};
|
|
1781
|
+
_builder;
|
|
1782
|
+
_manifestStore;
|
|
1783
|
+
_appSupervisor;
|
|
1784
|
+
_taskRunner;
|
|
1785
|
+
_appCount = 0;
|
|
1786
|
+
_lastPortUsed = 0;
|
|
1787
|
+
/**
|
|
1788
|
+
* Returns the resolved runtime configuration for the current host lifecycle.
|
|
1789
|
+
*
|
|
1790
|
+
* In production, the config is read from the global host runtime slot
|
|
1791
|
+
* exposed through `CFG_GLOBAL_KEY`. In other environments, the in-memory
|
|
1792
|
+
* loaded config snapshot is returned.
|
|
1793
|
+
*/
|
|
1794
|
+
get config() {
|
|
1795
|
+
return this.environment === "production" ? globalThis[CFG_GLOBAL_KEY] : this._config;
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Returns the environment mode assigned to this host instance.
|
|
1799
|
+
*/
|
|
1800
|
+
get environment() {
|
|
1801
|
+
return this.opts.environment;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Returns whether the supervised app worker has reported readiness.
|
|
1805
|
+
*/
|
|
1806
|
+
get isAppReady() {
|
|
1807
|
+
return this._appSupervisor.isReady;
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* Returns the currently loaded route manifest entries.
|
|
1811
|
+
*/
|
|
1812
|
+
get routes() {
|
|
1813
|
+
return this._manifestStore.routes;
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Returns the currently loaded event manifest entries.
|
|
1817
|
+
*/
|
|
1818
|
+
get events() {
|
|
1819
|
+
return this._manifestStore.events;
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Returns the currently loaded async task manifest entries.
|
|
1823
|
+
*/
|
|
1824
|
+
get tasks() {
|
|
1825
|
+
return this._manifestStore.tasks;
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Loads the user configuration file into the host runtime.
|
|
1829
|
+
*
|
|
1830
|
+
* This is skipped in production, where config is expected to be available
|
|
1831
|
+
* through the global runtime slot.
|
|
1832
|
+
*
|
|
1833
|
+
* @returns {Promise<void>} Resolves after the config snapshot has been
|
|
1834
|
+
* loaded when applicable.
|
|
1835
|
+
*/
|
|
1836
|
+
async loadConfig() {
|
|
1837
|
+
if (this.environment === "production") return;
|
|
1838
|
+
this._config = await loadConfig();
|
|
1839
|
+
this._lastPortUsed = this._config.http.port;
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Loads and merges configured environment files into the host snapshot.
|
|
1843
|
+
*
|
|
1844
|
+
* Files are loaded in config order, and later files override earlier keys.
|
|
1845
|
+
* Only files that currently exist are considered.
|
|
1846
|
+
*
|
|
1847
|
+
* @returns {Promise<Record<string, string>>} Copy of the merged environment
|
|
1848
|
+
* snapshot.
|
|
1849
|
+
* @throws {LithiaError} Throws when configuration has not been loaded yet.
|
|
1850
|
+
*/
|
|
1851
|
+
async loadEnv() {
|
|
1852
|
+
this.ensureConfigLoaded();
|
|
1853
|
+
const cwd = process.cwd();
|
|
1854
|
+
const envFiles = await this.getAvailableEnvFiles();
|
|
1855
|
+
const nextEnv = {};
|
|
1856
|
+
for (const file of envFiles) {
|
|
1857
|
+
try {
|
|
1858
|
+
const raw = await readFile(path.join(cwd, file), "utf-8");
|
|
1859
|
+
const parsed = parseEnv(raw);
|
|
1860
|
+
Object.assign(nextEnv, parsed);
|
|
1861
|
+
} catch {
|
|
1862
|
+
logger.warn(`Failed to parse env file: ${file}`);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
this._env = nextEnv;
|
|
1866
|
+
return { ...this._env };
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Loads the routes manifest from the current build output.
|
|
1870
|
+
*
|
|
1871
|
+
* @returns {Promise<void>} Resolves after the route manifest cache has been
|
|
1872
|
+
* refreshed.
|
|
1873
|
+
*/
|
|
1874
|
+
async loadRoutes() {
|
|
1875
|
+
await this._manifestStore.loadRoutes();
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Loads the events manifest from the current build output.
|
|
1879
|
+
*
|
|
1880
|
+
* @returns {Promise<void>} Resolves after the event manifest cache has been
|
|
1881
|
+
* refreshed.
|
|
1882
|
+
*/
|
|
1883
|
+
async loadEvents() {
|
|
1884
|
+
await this._manifestStore.loadEvents();
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Loads the async tasks manifest from the current build output.
|
|
1888
|
+
*
|
|
1889
|
+
* @returns {Promise<void>} Resolves after the task manifest cache has been
|
|
1890
|
+
* refreshed.
|
|
1891
|
+
*/
|
|
1892
|
+
async loadTasks() {
|
|
1893
|
+
await this._manifestStore.loadTasks();
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Returns a copy of the currently loaded environment snapshot.
|
|
1897
|
+
*
|
|
1898
|
+
* @returns {Record<string, string>} Shallow copy of the host environment
|
|
1899
|
+
* snapshot.
|
|
1900
|
+
*/
|
|
1901
|
+
getEnvSnapshot() {
|
|
1902
|
+
return { ...this._env };
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Replaces the in-memory resolved config snapshot.
|
|
1906
|
+
*
|
|
1907
|
+
* @param {LithiaOptions} config - Config snapshot that should replace the
|
|
1908
|
+
* current in-memory value.
|
|
1909
|
+
*/
|
|
1910
|
+
replaceConfig(config) {
|
|
1911
|
+
this._config = config;
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Replaces the in-memory environment snapshot.
|
|
1915
|
+
*
|
|
1916
|
+
* @param {Record<string, string>} env - Environment snapshot that should
|
|
1917
|
+
* replace the current in-memory value.
|
|
1918
|
+
*/
|
|
1919
|
+
replaceEnv(env) {
|
|
1920
|
+
this._env = { ...env };
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Builds the application output and manifests.
|
|
1924
|
+
*
|
|
1925
|
+
* Returns `true` when the build succeeds. In non-build environments, failures
|
|
1926
|
+
* are reported and surfaced as `false` so the caller can decide how to
|
|
1927
|
+
* recover.
|
|
1928
|
+
*
|
|
1929
|
+
* @returns {Promise<boolean>} `true` when the build succeeds, otherwise
|
|
1930
|
+
* `false` outside build mode.
|
|
1931
|
+
* @throws {unknown} Rethrows build failures when the host runs in `build`
|
|
1932
|
+
* mode.
|
|
1933
|
+
*/
|
|
1934
|
+
async build() {
|
|
1935
|
+
this.ensureConfigLoaded();
|
|
1936
|
+
const startTime = performance.now();
|
|
1937
|
+
try {
|
|
1938
|
+
await this._builder.build({
|
|
1939
|
+
sourceDir: this.config.sourceDir,
|
|
1940
|
+
outRoot: this.config.outDir,
|
|
1941
|
+
openapi: this.config.openapi
|
|
1942
|
+
});
|
|
1943
|
+
const duration = performance.now() - startTime;
|
|
1944
|
+
logger.success(`Compiled successfully in ${duration.toFixed(2)}ms.`);
|
|
1945
|
+
return true;
|
|
1946
|
+
} catch (error) {
|
|
1947
|
+
logger.error(`Build failed: ${error.message}`);
|
|
1948
|
+
if (this.environment === "build") throw error;
|
|
1949
|
+
return false;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Loads config/env and prints the CLI header for the current run.
|
|
1954
|
+
*
|
|
1955
|
+
* @returns {Promise<void>} Resolves after config, env, and header output are
|
|
1956
|
+
* ready.
|
|
1957
|
+
*/
|
|
1958
|
+
async setup() {
|
|
1959
|
+
await this.loadConfig();
|
|
1960
|
+
await this.loadEnv();
|
|
1961
|
+
await this.printHeader();
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Starts the app worker using the latest manifests and runtime state.
|
|
1965
|
+
*
|
|
1966
|
+
* @returns {Promise<void>} Resolves after manifests are loaded and the app
|
|
1967
|
+
* worker reports readiness.
|
|
1968
|
+
* @throws {Error} Throws when worker startup fails.
|
|
1969
|
+
*/
|
|
1970
|
+
async start() {
|
|
1971
|
+
if (!this._config) await this.loadConfig();
|
|
1972
|
+
await this._manifestStore.loadAll();
|
|
1973
|
+
await this._appSupervisor.start();
|
|
1974
|
+
logger.debug(`Instance started in ${this.environment} mode.`);
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Reloads manifests, resets task workers, and swaps the app worker.
|
|
1978
|
+
*
|
|
1979
|
+
* @returns {Promise<void>} Resolves after manifests are refreshed, task
|
|
1980
|
+
* workers are reset, and the replacement app worker becomes ready.
|
|
1981
|
+
*/
|
|
1982
|
+
async reload() {
|
|
1983
|
+
await this._manifestStore.loadAll();
|
|
1984
|
+
await this._taskRunner.reset();
|
|
1985
|
+
await this.swapApp();
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1988
|
+
* Stops task execution and tears down the app worker.
|
|
1989
|
+
*
|
|
1990
|
+
* @returns {Promise<void>} Resolves after warm task workers and the app
|
|
1991
|
+
* worker have been terminated.
|
|
1992
|
+
*/
|
|
1993
|
+
async stop() {
|
|
1994
|
+
await this._taskRunner.reset();
|
|
1995
|
+
await this._appSupervisor.dispose();
|
|
1996
|
+
logger.debug("Lithia instance stopped.");
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Replaces the current app worker with a fresh instance.
|
|
2000
|
+
*
|
|
2001
|
+
* @returns {Promise<void>} Resolves after the replacement app worker reports
|
|
2002
|
+
* readiness.
|
|
2003
|
+
*/
|
|
2004
|
+
async swapApp() {
|
|
2005
|
+
await this._appSupervisor.swap();
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Prints the Lithia CLI header and the env files currently in use.
|
|
2009
|
+
*
|
|
2010
|
+
* @returns {Promise<void>} Resolves after header output has been printed.
|
|
2011
|
+
*/
|
|
2012
|
+
async printHeader() {
|
|
2013
|
+
const files = await this.getAvailableEnvFiles();
|
|
2014
|
+
logger.event(green(`Lithia.js ${version}`));
|
|
2015
|
+
if (files.length) logger.info(`Environment: ${files.join(", ")}`);
|
|
2016
|
+
console.log();
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Prints the loaded routes in a CLI-friendly tree format.
|
|
2020
|
+
*/
|
|
2021
|
+
printRouteTree() {
|
|
2022
|
+
this.printTree(
|
|
2023
|
+
"Routes",
|
|
2024
|
+
this.routes,
|
|
2025
|
+
(route) => `${(route.method || "all").toUpperCase()} ${route.path}`,
|
|
2026
|
+
(route) => route.dynamic ? "\u0192" : "\u25CB"
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Prints the loaded events in a CLI-friendly tree format.
|
|
2031
|
+
*/
|
|
2032
|
+
printEventTree() {
|
|
2033
|
+
this.printTree(
|
|
2034
|
+
"Events",
|
|
2035
|
+
this.events,
|
|
2036
|
+
(event) => event.name,
|
|
2037
|
+
() => "\u03BB"
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Prints the loaded async tasks in a CLI-friendly tree format.
|
|
2042
|
+
*/
|
|
2043
|
+
printTaskTree() {
|
|
2044
|
+
this.printTree(
|
|
2045
|
+
"Async Tasks",
|
|
2046
|
+
this.tasks,
|
|
2047
|
+
(task) => task.id,
|
|
2048
|
+
(task) => task.trigger === "CRON" ? "\u29D6" : "\u2699"
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Forwards task invocation messages emitted by the app worker to the async
|
|
2053
|
+
* task runner.
|
|
2054
|
+
*
|
|
2055
|
+
* @param {AppToHostEvent} event - Message emitted by the app worker.
|
|
2056
|
+
* @returns {Promise<void>} Resolves after invocation messages are handled or
|
|
2057
|
+
* ignored.
|
|
2058
|
+
*/
|
|
2059
|
+
async handleAppMessage(event) {
|
|
2060
|
+
if (event.type !== "invoke") return;
|
|
2061
|
+
await this._taskRunner.handleInvocation(event);
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Prints a simple labeled tree for CLI inspection of loaded runtime state.
|
|
2065
|
+
*
|
|
2066
|
+
* @param {string} label - Section label printed above the tree.
|
|
2067
|
+
* @param {T[]} items - Items to render.
|
|
2068
|
+
* @param {(item: T) => string} nameFn - Formatter used for the item label.
|
|
2069
|
+
* @param {(item: T) => string} symbolFn - Formatter used for the item
|
|
2070
|
+
* prefix symbol.
|
|
2071
|
+
*/
|
|
2072
|
+
printTree(label, items, nameFn, symbolFn) {
|
|
2073
|
+
if (items.length === 0) return;
|
|
2074
|
+
console.log(`
|
|
2075
|
+
\x1B[4m${label}:\x1B[0m`);
|
|
2076
|
+
items.forEach((item, index) => {
|
|
2077
|
+
const branch = index === items.length - 1 ? "\u2514" : "\u251C";
|
|
2078
|
+
console.log(`${branch} ${symbolFn(item)} ${nameFn(item)}`);
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Returns the configured env files that currently exist on disk.
|
|
2083
|
+
*
|
|
2084
|
+
* @returns {Promise<string[]>} Existing env files in configured load order.
|
|
2085
|
+
*/
|
|
2086
|
+
async getAvailableEnvFiles() {
|
|
2087
|
+
const existing = [];
|
|
2088
|
+
for (const file of this.config.envFiles) {
|
|
2089
|
+
if (await fileExists(path.join(process.cwd(), file))) existing.push(file);
|
|
2090
|
+
}
|
|
2091
|
+
return existing;
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Verifies that a config snapshot is available before host operations that
|
|
2095
|
+
* depend on it.
|
|
2096
|
+
*
|
|
2097
|
+
* @throws {LithiaError} Throws when configuration has not been loaded.
|
|
2098
|
+
*/
|
|
2099
|
+
ensureConfigLoaded() {
|
|
2100
|
+
if (!this.config) throw new LithiaError("Configuration not loaded.");
|
|
2101
|
+
}
|
|
2102
|
+
};
|
|
2103
|
+
|
|
2104
|
+
export { CFG_GLOBAL_KEY, HostSupervisor, NotInLithiaContextError, RouteNotFoundError };
|
|
2105
|
+
//# sourceMappingURL=_index.mjs.map
|
|
2106
|
+
//# sourceMappingURL=_index.mjs.map
|