@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.
Files changed (102) hide show
  1. package/README.md +26 -43
  2. package/dist/_index.d.ts +245 -0
  3. package/dist/_index.mjs +2106 -0
  4. package/dist/_index.mjs.map +1 -0
  5. package/dist/index.d.ts +824 -0
  6. package/dist/index.mjs +856 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/dist/protocol-DBwVPJYN.d.ts +332 -0
  9. package/dist/tasks-X-3clDS8.d.ts +31 -0
  10. package/dist/workers/app-worker.d.ts +2 -0
  11. package/dist/workers/app-worker.mjs +1907 -0
  12. package/dist/workers/app-worker.mjs.map +1 -0
  13. package/dist/workers/task-worker.d.ts +45 -0
  14. package/dist/workers/task-worker.mjs +146 -0
  15. package/dist/workers/task-worker.mjs.map +1 -0
  16. package/package.json +47 -23
  17. package/CHANGELOG.md +0 -31
  18. package/dist/config.d.ts +0 -101
  19. package/dist/config.js +0 -113
  20. package/dist/config.js.map +0 -1
  21. package/dist/context/event-context.d.ts +0 -53
  22. package/dist/context/event-context.js +0 -42
  23. package/dist/context/event-context.js.map +0 -1
  24. package/dist/context/index.d.ts +0 -16
  25. package/dist/context/index.js +0 -29
  26. package/dist/context/index.js.map +0 -1
  27. package/dist/context/lithia-context.d.ts +0 -47
  28. package/dist/context/lithia-context.js +0 -43
  29. package/dist/context/lithia-context.js.map +0 -1
  30. package/dist/context/route-context.d.ts +0 -74
  31. package/dist/context/route-context.js +0 -42
  32. package/dist/context/route-context.js.map +0 -1
  33. package/dist/env.d.ts +0 -1
  34. package/dist/env.js +0 -32
  35. package/dist/env.js.map +0 -1
  36. package/dist/errors.d.ts +0 -51
  37. package/dist/errors.js +0 -80
  38. package/dist/errors.js.map +0 -1
  39. package/dist/hooks/dependency-hooks.d.ts +0 -105
  40. package/dist/hooks/dependency-hooks.js +0 -96
  41. package/dist/hooks/dependency-hooks.js.map +0 -1
  42. package/dist/hooks/event-hooks.d.ts +0 -61
  43. package/dist/hooks/event-hooks.js +0 -70
  44. package/dist/hooks/event-hooks.js.map +0 -1
  45. package/dist/hooks/index.d.ts +0 -41
  46. package/dist/hooks/index.js +0 -59
  47. package/dist/hooks/index.js.map +0 -1
  48. package/dist/hooks/route-hooks.d.ts +0 -154
  49. package/dist/hooks/route-hooks.js +0 -174
  50. package/dist/hooks/route-hooks.js.map +0 -1
  51. package/dist/lib.d.ts +0 -10
  52. package/dist/lib.js +0 -30
  53. package/dist/lib.js.map +0 -1
  54. package/dist/lithia.d.ts +0 -447
  55. package/dist/lithia.js +0 -649
  56. package/dist/lithia.js.map +0 -1
  57. package/dist/logger.d.ts +0 -11
  58. package/dist/logger.js +0 -55
  59. package/dist/logger.js.map +0 -1
  60. package/dist/module-loader.d.ts +0 -12
  61. package/dist/module-loader.js +0 -78
  62. package/dist/module-loader.js.map +0 -1
  63. package/dist/server/event-processor.d.ts +0 -195
  64. package/dist/server/event-processor.js +0 -253
  65. package/dist/server/event-processor.js.map +0 -1
  66. package/dist/server/http-server.d.ts +0 -196
  67. package/dist/server/http-server.js +0 -295
  68. package/dist/server/http-server.js.map +0 -1
  69. package/dist/server/middlewares/validation.d.ts +0 -12
  70. package/dist/server/middlewares/validation.js +0 -34
  71. package/dist/server/middlewares/validation.js.map +0 -1
  72. package/dist/server/request-processor.d.ts +0 -400
  73. package/dist/server/request-processor.js +0 -652
  74. package/dist/server/request-processor.js.map +0 -1
  75. package/dist/server/request.d.ts +0 -73
  76. package/dist/server/request.js +0 -207
  77. package/dist/server/request.js.map +0 -1
  78. package/dist/server/response.d.ts +0 -69
  79. package/dist/server/response.js +0 -173
  80. package/dist/server/response.js.map +0 -1
  81. package/src/config.ts +0 -212
  82. package/src/context/event-context.ts +0 -66
  83. package/src/context/index.ts +0 -32
  84. package/src/context/lithia-context.ts +0 -59
  85. package/src/context/route-context.ts +0 -89
  86. package/src/env.ts +0 -31
  87. package/src/errors.ts +0 -96
  88. package/src/hooks/dependency-hooks.ts +0 -122
  89. package/src/hooks/event-hooks.ts +0 -69
  90. package/src/hooks/index.ts +0 -58
  91. package/src/hooks/route-hooks.ts +0 -177
  92. package/src/lib.ts +0 -27
  93. package/src/lithia.ts +0 -777
  94. package/src/logger.ts +0 -66
  95. package/src/module-loader.ts +0 -45
  96. package/src/server/event-processor.ts +0 -344
  97. package/src/server/http-server.ts +0 -371
  98. package/src/server/middlewares/validation.ts +0 -46
  99. package/src/server/request-processor.ts +0 -860
  100. package/src/server/request.ts +0 -247
  101. package/src/server/response.ts +0 -204
  102. package/tsconfig.json +0 -8
@@ -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