@forklaunch/hyper-express 0.7.11 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.mts +33 -37
- package/lib/index.d.ts +33 -37
- package/lib/index.js +310 -59
- package/lib/index.mjs +312 -59
- package/package.json +12 -12
package/lib/index.mjs
CHANGED
@@ -83,16 +83,158 @@ import { getEnvVar, safeStringify as safeStringify2 } from "@forklaunch/common";
|
|
83
83
|
import {
|
84
84
|
ATTR_HTTP_RESPONSE_STATUS_CODE,
|
85
85
|
ForklaunchExpressLikeApplication,
|
86
|
+
generateMcpServer,
|
86
87
|
generateOpenApiSpecs,
|
87
88
|
isForklaunchRequest,
|
89
|
+
isPortBound,
|
88
90
|
OPENAPI_DEFAULT_VERSION
|
89
91
|
} from "@forklaunch/core/http";
|
90
92
|
import {
|
91
93
|
Server
|
92
94
|
} from "@forklaunch/hyper-express-fork";
|
95
|
+
import { ZodSchemaValidator } from "@forklaunch/validator/zod";
|
93
96
|
import { apiReference } from "@scalar/express-api-reference";
|
94
97
|
import crypto from "crypto";
|
95
98
|
|
99
|
+
// src/cluster/hyperExpress.cluster.ts
|
100
|
+
import cluster from "cluster";
|
101
|
+
import * as os from "os";
|
102
|
+
import * as uWS from "uWebSockets.js";
|
103
|
+
function startHyperExpressCluster(config) {
|
104
|
+
const PORT = config.port;
|
105
|
+
const HOST = config.host;
|
106
|
+
const WORKERS = config.workerCount;
|
107
|
+
const app = config.expressApp;
|
108
|
+
const openTelemetryCollector = config.openTelemetryCollector;
|
109
|
+
if (WORKERS > os.cpus().length) {
|
110
|
+
throw new Error("Worker count cannot be greater than the number of CPUs");
|
111
|
+
}
|
112
|
+
if (cluster.isPrimary) {
|
113
|
+
let startWorker2 = function(i) {
|
114
|
+
if (isShuttingDown) return;
|
115
|
+
const worker = cluster.fork({
|
116
|
+
WORKER_INDEX: i.toString(),
|
117
|
+
PORT: PORT.toString(),
|
118
|
+
HOST
|
119
|
+
});
|
120
|
+
workers.set(worker.id, worker);
|
121
|
+
worker.on("exit", () => {
|
122
|
+
if (isShuttingDown) {
|
123
|
+
return;
|
124
|
+
}
|
125
|
+
workers.delete(worker.id);
|
126
|
+
openTelemetryCollector.warn(
|
127
|
+
`worker ${worker.process.pid} died; restarting in 2s`
|
128
|
+
);
|
129
|
+
setTimeout(() => {
|
130
|
+
if (!isShuttingDown) {
|
131
|
+
startWorker2(i);
|
132
|
+
}
|
133
|
+
}, 2e3);
|
134
|
+
});
|
135
|
+
worker.on(
|
136
|
+
"message",
|
137
|
+
(message) => {
|
138
|
+
if (message && message.type === "worker-ready") {
|
139
|
+
openTelemetryCollector.info(
|
140
|
+
`Worker ${message.index || i} (PID: ${worker.process.pid}) ready`
|
141
|
+
);
|
142
|
+
}
|
143
|
+
}
|
144
|
+
);
|
145
|
+
};
|
146
|
+
var startWorker = startWorker2;
|
147
|
+
const workers = /* @__PURE__ */ new Map();
|
148
|
+
let isShuttingDown = false;
|
149
|
+
openTelemetryCollector.info(
|
150
|
+
`[primary ${process.pid}] starting ${WORKERS} workers on ${HOST}:${PORT}. Using SO_REUSEPORT kernel level routing.`
|
151
|
+
);
|
152
|
+
for (let i = 0; i < WORKERS; i++) {
|
153
|
+
startWorker2(i);
|
154
|
+
}
|
155
|
+
process.on("SIGINT", () => {
|
156
|
+
if (isShuttingDown) return;
|
157
|
+
isShuttingDown = true;
|
158
|
+
openTelemetryCollector.info(
|
159
|
+
"Received SIGINT, shutting down gracefully..."
|
160
|
+
);
|
161
|
+
workers.forEach((worker) => {
|
162
|
+
worker.send({ type: "shutdown" });
|
163
|
+
worker.kill("SIGTERM");
|
164
|
+
});
|
165
|
+
setTimeout(() => {
|
166
|
+
workers.forEach((worker) => worker.kill("SIGKILL"));
|
167
|
+
process.exit(0);
|
168
|
+
}, 5e3);
|
169
|
+
});
|
170
|
+
} else {
|
171
|
+
const WORKER_PORT = parseInt(process.env.PORT || "0");
|
172
|
+
const IDX = process.env.WORKER_INDEX ?? cluster.worker?.id ?? "0";
|
173
|
+
if (!WORKER_PORT) {
|
174
|
+
openTelemetryCollector.error("Worker port not provided");
|
175
|
+
process.exit(1);
|
176
|
+
}
|
177
|
+
let listenSocket = null;
|
178
|
+
try {
|
179
|
+
app.listen(WORKER_PORT, (socket) => {
|
180
|
+
listenSocket = socket;
|
181
|
+
if (process.send) {
|
182
|
+
process.send({ type: "worker-ready", port: WORKER_PORT, index: IDX });
|
183
|
+
}
|
184
|
+
openTelemetryCollector.info(
|
185
|
+
`[worker ${process.pid}] listening on shared ${HOST}:${WORKER_PORT}`
|
186
|
+
);
|
187
|
+
});
|
188
|
+
} catch (error) {
|
189
|
+
openTelemetryCollector.error("Failed to start worker:", error);
|
190
|
+
process.exit(1);
|
191
|
+
}
|
192
|
+
process.on("message", (msg) => {
|
193
|
+
if (msg?.type === "shutdown") {
|
194
|
+
openTelemetryCollector.info(
|
195
|
+
`[worker ${process.pid}] received shutdown signal from primary`
|
196
|
+
);
|
197
|
+
if (listenSocket) {
|
198
|
+
uWS.us_listen_socket_close(listenSocket);
|
199
|
+
}
|
200
|
+
process.exit(0);
|
201
|
+
}
|
202
|
+
});
|
203
|
+
process.on("SIGINT", () => {
|
204
|
+
openTelemetryCollector.info(
|
205
|
+
`[worker ${process.pid}] shutting down gracefully...`
|
206
|
+
);
|
207
|
+
if (listenSocket) {
|
208
|
+
uWS.us_listen_socket_close(listenSocket);
|
209
|
+
}
|
210
|
+
process.exit(0);
|
211
|
+
});
|
212
|
+
setInterval(() => {
|
213
|
+
const memUsage = process.memoryUsage();
|
214
|
+
const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
|
215
|
+
if (heapUsedMB > 100) {
|
216
|
+
openTelemetryCollector.warn(
|
217
|
+
`High memory usage on worker ${process.pid}: ${heapUsedMB} MB`
|
218
|
+
);
|
219
|
+
}
|
220
|
+
}, 3e4);
|
221
|
+
process.on("uncaughtException", (err) => {
|
222
|
+
openTelemetryCollector.error(
|
223
|
+
`Uncaught exception on worker ${process.pid}:`,
|
224
|
+
err
|
225
|
+
);
|
226
|
+
process.exit(1);
|
227
|
+
});
|
228
|
+
process.on("unhandledRejection", (reason) => {
|
229
|
+
openTelemetryCollector.error(
|
230
|
+
`Unhandled rejection on worker ${process.pid}:`,
|
231
|
+
reason
|
232
|
+
);
|
233
|
+
process.exit(1);
|
234
|
+
});
|
235
|
+
}
|
236
|
+
}
|
237
|
+
|
96
238
|
// src/middleware/contentParse.middleware.ts
|
97
239
|
import { isNever } from "@forklaunch/common";
|
98
240
|
import { discriminateBody } from "@forklaunch/core/http";
|
@@ -275,6 +417,9 @@ function swagger(path, document, opts, options2, customCss, customfavIcon, swagg
|
|
275
417
|
// src/hyperExpressApplication.ts
|
276
418
|
var Application = class extends ForklaunchExpressLikeApplication {
|
277
419
|
docsConfiguration;
|
420
|
+
mcpConfiguration;
|
421
|
+
openapiConfiguration;
|
422
|
+
hostingConfiguration;
|
278
423
|
/**
|
279
424
|
* Creates an instance of the Application class.
|
280
425
|
*
|
@@ -294,7 +439,11 @@ var Application = class extends ForklaunchExpressLikeApplication {
|
|
294
439
|
constructor(schemaValidator, openTelemetryCollector, configurationOptions) {
|
295
440
|
super(
|
296
441
|
schemaValidator,
|
297
|
-
new Server(
|
442
|
+
new Server({
|
443
|
+
key_file_name: configurationOptions?.hosting?.ssl?.keyFile,
|
444
|
+
cert_file_name: configurationOptions?.hosting?.ssl?.certFile,
|
445
|
+
...configurationOptions?.server
|
446
|
+
}),
|
298
447
|
[
|
299
448
|
contentParse(configurationOptions),
|
300
449
|
enrichResponseTransmission
|
@@ -302,7 +451,10 @@ var Application = class extends ForklaunchExpressLikeApplication {
|
|
302
451
|
openTelemetryCollector,
|
303
452
|
configurationOptions
|
304
453
|
);
|
454
|
+
this.hostingConfiguration = configurationOptions?.hosting;
|
305
455
|
this.docsConfiguration = configurationOptions?.docs;
|
456
|
+
this.mcpConfiguration = configurationOptions?.mcp;
|
457
|
+
this.openapiConfiguration = configurationOptions?.openapi;
|
306
458
|
}
|
307
459
|
async listen(arg0, arg1, arg2) {
|
308
460
|
if (typeof arg0 === "number") {
|
@@ -322,66 +474,162 @@ Correlation id: ${isForklaunchRequest(req) ? req.context.correlationId : "No cor
|
|
322
474
|
[ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode ?? 500
|
323
475
|
});
|
324
476
|
});
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
},
|
347
|
-
...Object.entries(openApi).map(([version, spec]) => ({
|
348
|
-
content: spec,
|
349
|
-
title: `API Reference - ${version}`
|
350
|
-
})),
|
351
|
-
...this.docsConfiguration?.sources ?? []
|
352
|
-
]
|
353
|
-
})
|
477
|
+
if (this.schemaValidator instanceof ZodSchemaValidator && this.mcpConfiguration !== false) {
|
478
|
+
const {
|
479
|
+
port: mcpPort,
|
480
|
+
options: options2,
|
481
|
+
path: mcpPath,
|
482
|
+
version,
|
483
|
+
additionalTools,
|
484
|
+
contentTypeMapping
|
485
|
+
} = this.mcpConfiguration ?? {};
|
486
|
+
const zodSchemaValidator = this.schemaValidator;
|
487
|
+
const finalMcpPort = mcpPort ?? port + 2e3;
|
488
|
+
const mcpServer = generateMcpServer(
|
489
|
+
zodSchemaValidator,
|
490
|
+
protocol,
|
491
|
+
host,
|
492
|
+
finalMcpPort,
|
493
|
+
version ?? "1.0.0",
|
494
|
+
this,
|
495
|
+
this.mcpConfiguration,
|
496
|
+
options2,
|
497
|
+
contentTypeMapping
|
354
498
|
);
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
499
|
+
if (additionalTools) {
|
500
|
+
additionalTools(mcpServer);
|
501
|
+
}
|
502
|
+
if (this.hostingConfiguration?.workerCount && this.hostingConfiguration.workerCount > 1) {
|
503
|
+
isPortBound(finalMcpPort, host).then((isBound) => {
|
504
|
+
if (!isBound) {
|
505
|
+
mcpServer.start({
|
506
|
+
httpStream: {
|
507
|
+
host,
|
508
|
+
endpoint: mcpPath ?? "/mcp",
|
509
|
+
port: finalMcpPort
|
510
|
+
},
|
511
|
+
transportType: "httpStream"
|
512
|
+
});
|
513
|
+
}
|
514
|
+
});
|
515
|
+
} else {
|
516
|
+
mcpServer.start({
|
517
|
+
httpStream: {
|
518
|
+
host,
|
519
|
+
endpoint: mcpPath ?? "/mcp",
|
520
|
+
port: finalMcpPort
|
521
|
+
},
|
522
|
+
transportType: "httpStream"
|
375
523
|
});
|
376
524
|
}
|
377
|
-
|
378
|
-
this.
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
525
|
+
}
|
526
|
+
if (this.openapiConfiguration !== false) {
|
527
|
+
const openApiServerUrls = getEnvVar("DOCS_SERVER_URLS")?.split(",") ?? [
|
528
|
+
`${protocol}://${host}:${port}`
|
529
|
+
];
|
530
|
+
const openApiServerDescriptions = getEnvVar(
|
531
|
+
"DOCS_SERVER_DESCRIPTIONS"
|
532
|
+
)?.split(",") ?? ["Main Server"];
|
533
|
+
const openApi = generateOpenApiSpecs(
|
534
|
+
this.schemaValidator,
|
535
|
+
openApiServerUrls,
|
536
|
+
openApiServerDescriptions,
|
537
|
+
this,
|
538
|
+
this.openapiConfiguration
|
539
|
+
);
|
540
|
+
if (this.docsConfiguration == null || this.docsConfiguration.type === "scalar") {
|
541
|
+
this.internal.use(
|
542
|
+
`/api${process.env.VERSION ? `/${process.env.VERSION}` : ""}${process.env.DOCS_PATH ?? "/docs"}`,
|
543
|
+
apiReference({
|
544
|
+
...this.docsConfiguration,
|
545
|
+
sources: [
|
546
|
+
{
|
547
|
+
content: openApi[OPENAPI_DEFAULT_VERSION],
|
548
|
+
title: "API Reference"
|
549
|
+
},
|
550
|
+
...Object.entries(openApi).map(([version, spec]) => ({
|
551
|
+
content: typeof this.openapiConfiguration === "boolean" ? spec : this.openapiConfiguration?.discreteVersions === false ? {
|
552
|
+
...openApi[OPENAPI_DEFAULT_VERSION],
|
553
|
+
...spec
|
554
|
+
} : spec,
|
555
|
+
title: `API Reference - ${version}`
|
556
|
+
})),
|
557
|
+
...this.docsConfiguration?.sources ?? []
|
558
|
+
]
|
559
|
+
})
|
560
|
+
);
|
561
|
+
} else if (this.docsConfiguration?.type === "swagger") {
|
562
|
+
const swaggerPath = `/api${process.env.VERSION ? `/${process.env.VERSION}` : ""}${process.env.DOCS_PATH ?? "/docs"}`;
|
563
|
+
Object.entries(openApi).forEach(([version, spec]) => {
|
564
|
+
const versionPath = encodeURIComponent(`${swaggerPath}/${version}`);
|
565
|
+
this.internal.use(versionPath, swaggerRedirect(versionPath));
|
566
|
+
this.internal.get(`${versionPath}/*`, swagger(versionPath, spec));
|
567
|
+
});
|
383
568
|
}
|
384
|
-
|
569
|
+
this.internal.get(
|
570
|
+
`/api${process.env.VERSION ? `/${process.env.VERSION}` : ""}/openapi`,
|
571
|
+
(_, res) => {
|
572
|
+
res.type("application/json");
|
573
|
+
res.json({
|
574
|
+
latest: openApi[OPENAPI_DEFAULT_VERSION],
|
575
|
+
...Object.fromEntries(
|
576
|
+
Object.entries(openApi).map(([version, spec]) => [
|
577
|
+
`v${version}`,
|
578
|
+
typeof this.openapiConfiguration === "boolean" ? spec : this.openapiConfiguration?.discreteVersions === false ? {
|
579
|
+
...openApi[OPENAPI_DEFAULT_VERSION],
|
580
|
+
...spec
|
581
|
+
} : spec
|
582
|
+
])
|
583
|
+
)
|
584
|
+
});
|
585
|
+
}
|
586
|
+
);
|
587
|
+
this.internal.get(
|
588
|
+
`/api${process.env.VERSION ? `/${process.env.VERSION}` : ""}/openapi/:id`,
|
589
|
+
async (req, res) => {
|
590
|
+
res.type("application/json");
|
591
|
+
if (req.params.id === "latest") {
|
592
|
+
res.json(openApi[OPENAPI_DEFAULT_VERSION]);
|
593
|
+
} else {
|
594
|
+
if (openApi[req.params.id] == null) {
|
595
|
+
res.status(404).send("Not Found");
|
596
|
+
return;
|
597
|
+
}
|
598
|
+
res.json(
|
599
|
+
typeof this.openapiConfiguration === "boolean" ? openApi[req.params.id] : this.openapiConfiguration?.discreteVersions === false ? {
|
600
|
+
...openApi[OPENAPI_DEFAULT_VERSION],
|
601
|
+
...openApi[req.params.id]
|
602
|
+
} : openApi[req.params.id]
|
603
|
+
);
|
604
|
+
}
|
605
|
+
}
|
606
|
+
);
|
607
|
+
this.internal.get(
|
608
|
+
`/api/${process.env.VERSION ?? "v1"}/openapi-hash`,
|
609
|
+
async (_, res) => {
|
610
|
+
const hash = await crypto.createHash("sha256").update(safeStringify2(openApi)).digest("hex");
|
611
|
+
res.send(hash);
|
612
|
+
}
|
613
|
+
);
|
614
|
+
}
|
615
|
+
this.internal.get("/health", (_, res) => {
|
616
|
+
res.send("OK");
|
617
|
+
});
|
618
|
+
const { workerCount, ssl, routingStrategy } = this.hostingConfiguration ?? {};
|
619
|
+
if (workerCount != null && workerCount > 1) {
|
620
|
+
this.openTelemetryCollector.warn(
|
621
|
+
"Clustering with hyper-express will default to kernel-level routing."
|
622
|
+
);
|
623
|
+
startHyperExpressCluster({
|
624
|
+
expressApp: this.internal,
|
625
|
+
openTelemetryCollector: this.openTelemetryCollector,
|
626
|
+
port,
|
627
|
+
host,
|
628
|
+
workerCount,
|
629
|
+
routingStrategy,
|
630
|
+
ssl
|
631
|
+
});
|
632
|
+
}
|
385
633
|
if (arg1 && typeof arg1 === "string") {
|
386
634
|
return this.internal.listen(port, arg1, arg2);
|
387
635
|
} else if (arg1 && typeof arg1 === "function") {
|
@@ -422,7 +670,8 @@ var Router = class _Router extends ForklaunchExpressLikeRouter {
|
|
422
670
|
contentParse(options2),
|
423
671
|
enrichResponseTransmission
|
424
672
|
],
|
425
|
-
openTelemetryCollector
|
673
|
+
openTelemetryCollector,
|
674
|
+
options2
|
426
675
|
);
|
427
676
|
this.basePath = basePath;
|
428
677
|
this.configOptions = options2;
|
@@ -458,7 +707,11 @@ var Router = class _Router extends ForklaunchExpressLikeRouter {
|
|
458
707
|
|
459
708
|
// index.ts
|
460
709
|
function forklaunchExpress(schemaValidator, openTelemetryCollector, options2) {
|
461
|
-
return new Application(
|
710
|
+
return new Application(
|
711
|
+
schemaValidator,
|
712
|
+
openTelemetryCollector,
|
713
|
+
options2
|
714
|
+
);
|
462
715
|
}
|
463
716
|
function forklaunchRouter(basePath, schemaValidator, openTelemetryCollector, options2) {
|
464
717
|
const router = new Router(
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@forklaunch/hyper-express",
|
3
|
-
"version": "0.
|
3
|
+
"version": "0.8.0",
|
4
4
|
"description": "Forklaunch framework for hyper-express.",
|
5
5
|
"homepage": "https://github.com/forklaunch/forklaunch-js#readme",
|
6
6
|
"bugs": {
|
@@ -25,7 +25,7 @@
|
|
25
25
|
"lib/**"
|
26
26
|
],
|
27
27
|
"dependencies": {
|
28
|
-
"@forklaunch/hyper-express-fork": "^6.17.
|
28
|
+
"@forklaunch/hyper-express-fork": "^6.17.35",
|
29
29
|
"@scalar/express-api-reference": "^0.8.13",
|
30
30
|
"cors": "^2.8.5",
|
31
31
|
"live-directory": "^3.0.3",
|
@@ -33,30 +33,30 @@
|
|
33
33
|
"qs": "^6.14.0",
|
34
34
|
"swagger-ui-dist": "^5.27.1",
|
35
35
|
"swagger-ui-express": "^5.0.1",
|
36
|
-
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.
|
37
|
-
"@forklaunch/common": "0.
|
38
|
-
"@forklaunch/validator": "0.
|
39
|
-
"@forklaunch/core": "0.
|
36
|
+
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.52.0",
|
37
|
+
"@forklaunch/common": "0.5.0",
|
38
|
+
"@forklaunch/validator": "0.9.0",
|
39
|
+
"@forklaunch/core": "0.13.0"
|
40
40
|
},
|
41
41
|
"devDependencies": {
|
42
|
-
"@eslint/js": "^9.
|
42
|
+
"@eslint/js": "^9.33.0",
|
43
43
|
"@types/busboy": "^1.5.4",
|
44
44
|
"@types/cors": "^2.8.19",
|
45
45
|
"@types/jest": "^30.0.0",
|
46
46
|
"@types/qs": "^6.14.0",
|
47
47
|
"@types/swagger-ui-dist": "^3.30.6",
|
48
48
|
"@types/swagger-ui-express": "^4.1.8",
|
49
|
-
"@typescript/native-preview": "7.0.0-dev.
|
49
|
+
"@typescript/native-preview": "7.0.0-dev.20250820.1",
|
50
50
|
"jest": "^30.0.5",
|
51
51
|
"kill-port-process": "^3.2.1",
|
52
52
|
"prettier": "^3.6.2",
|
53
|
-
"ts-jest": "^29.4.
|
53
|
+
"ts-jest": "^29.4.1",
|
54
54
|
"ts-node": "^10.9.2",
|
55
55
|
"tsup": "^8.5.0",
|
56
|
-
"tsx": "^4.20.
|
57
|
-
"typedoc": "^0.28.
|
56
|
+
"tsx": "^4.20.4",
|
57
|
+
"typedoc": "^0.28.10",
|
58
58
|
"typescript": "^5.9.2",
|
59
|
-
"typescript-eslint": "^8.
|
59
|
+
"typescript-eslint": "^8.40.0"
|
60
60
|
},
|
61
61
|
"scripts": {
|
62
62
|
"build": "tsgo --noEmit && tsup index.ts --format cjs,esm --no-splitting --dts --tsconfig tsconfig.json --out-dir lib --clean",
|