@jeffusion/bungee-core 4.0.0 → 4.1.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.
Files changed (3) hide show
  1. package/dist/master.js +405 -87
  2. package/dist/worker.js +1086 -374
  3. package/package.json +2 -2
package/dist/master.js CHANGED
@@ -16047,7 +16047,7 @@ var init_migrations = __esm(() => {
16047
16047
  });
16048
16048
 
16049
16049
  // src/config-migrations/types.ts
16050
- var LATEST_CONFIG_VERSION = 2;
16050
+ var LATEST_CONFIG_VERSION = 4;
16051
16051
 
16052
16052
  // src/config-migrations/utils.ts
16053
16053
  function isPlainObject(value) {
@@ -16110,7 +16110,7 @@ function detectConfigVersion(rawConfig) {
16110
16110
  if (!isPlainObject(rawConfig)) {
16111
16111
  return 1;
16112
16112
  }
16113
- const version = rawConfig.configVersion;
16113
+ const version = rawConfig.configVersion ?? rawConfig.config_version;
16114
16114
  return typeof version === "number" && Number.isInteger(version) && version > 0 ? version : 1;
16115
16115
  }
16116
16116
  function isPlainRecord(value) {
@@ -16151,7 +16151,7 @@ var init_v1_to_v2 = __esm(() => {
16151
16151
  ];
16152
16152
  v1ToV2Migration = {
16153
16153
  fromVersion: 1,
16154
- toVersion: LATEST_CONFIG_VERSION,
16154
+ toVersion: 2,
16155
16155
  description: "Migrate legacy failover route fields to config model v2",
16156
16156
  migrate(input) {
16157
16157
  const config = cloneJson(input);
@@ -16184,7 +16184,259 @@ var init_v1_to_v2 = __esm(() => {
16184
16184
  }
16185
16185
  return cleanupEmptyObjects(migratedRoute);
16186
16186
  });
16187
- config.configVersion = LATEST_CONFIG_VERSION;
16187
+ config.configVersion = 2;
16188
+ return {
16189
+ config: cleanupEmptyObjects(config),
16190
+ changes,
16191
+ warnings
16192
+ };
16193
+ }
16194
+ };
16195
+ });
16196
+
16197
+ // src/config-migrations/versions/v2-to-v3.ts
16198
+ function renameKeysRecursive(obj, mappings, changes) {
16199
+ if (Array.isArray(obj)) {
16200
+ return obj.map((item) => renameKeysRecursive(item, mappings, changes));
16201
+ }
16202
+ if (!isPlainRecord(obj)) {
16203
+ return obj;
16204
+ }
16205
+ const result = {};
16206
+ for (const [key, value] of Object.entries(obj)) {
16207
+ const mapping = mappings.find(([from]) => from === key);
16208
+ const newKey = mapping ? mapping[1] : key;
16209
+ if (mapping) {
16210
+ changes.push({
16211
+ type: "rename",
16212
+ path: key,
16213
+ from: key,
16214
+ to: newKey,
16215
+ message: `Renamed ${key} to ${newKey}`
16216
+ });
16217
+ }
16218
+ result[newKey] = renameKeysRecursive(value, mappings, changes);
16219
+ }
16220
+ return result;
16221
+ }
16222
+ function migrateRoutesToServiceRef(config, changes, warnings) {
16223
+ if (!Array.isArray(config.routes))
16224
+ return;
16225
+ const upstreamSignatureMap = new Map;
16226
+ const services = [];
16227
+ for (const route of config.routes) {
16228
+ if (!isPlainRecord(route))
16229
+ continue;
16230
+ const endpoints = route.endpoints;
16231
+ if (!Array.isArray(endpoints) || endpoints.length === 0) {
16232
+ warnings.push({
16233
+ path: `route "${route.path || "unknown"}"`,
16234
+ message: "Route has no endpoints; skipping service extraction"
16235
+ });
16236
+ continue;
16237
+ }
16238
+ const signature = JSON.stringify(endpoints.map((u) => ({
16239
+ target: u.target,
16240
+ weight: u.weight,
16241
+ priority: u.priority
16242
+ })));
16243
+ let serviceName = upstreamSignatureMap.get(signature);
16244
+ if (!serviceName) {
16245
+ serviceName = `service-${services.length + 1}`;
16246
+ upstreamSignatureMap.set(signature, serviceName);
16247
+ const service = {
16248
+ name: serviceName,
16249
+ endpoints
16250
+ };
16251
+ if (isPlainRecord(route.health_check)) {
16252
+ service.health_check = route.health_check;
16253
+ }
16254
+ services.push(service);
16255
+ changes.push({
16256
+ type: "move",
16257
+ path: `route "${route.path}".endpoints`,
16258
+ from: "route.endpoints",
16259
+ to: `services.${serviceName}.endpoints`,
16260
+ message: `Extracted endpoints to service "${serviceName}"`
16261
+ });
16262
+ }
16263
+ route.service = serviceName;
16264
+ delete route.endpoints;
16265
+ }
16266
+ config.services = services;
16267
+ }
16268
+ var CAMEL_TO_SNAKE_MAPPINGS, v2ToV3Migration;
16269
+ var init_v2_to_v3 = __esm(() => {
16270
+ CAMEL_TO_SNAKE_MAPPINGS = [
16271
+ ["configVersion", "config_version"],
16272
+ ["logLevel", "log_level"],
16273
+ ["bodyParserLimit", "body_parser_limit"],
16274
+ ["pathRewrite", "path_rewrite"],
16275
+ ["stickySession", "sticky_session"],
16276
+ ["keyExpression", "key_expression"],
16277
+ ["connectMs", "connect_ms"],
16278
+ ["requestMs", "request_ms"],
16279
+ ["retryOn", "retry_on"],
16280
+ ["passiveHealth", "passive_health"],
16281
+ ["consecutiveFailures", "consecutive_failures"],
16282
+ ["healthySuccesses", "healthy_successes"],
16283
+ ["autoDisableThreshold", "auto_disable_threshold"],
16284
+ ["autoEnableOnActiveHealthCheck", "auto_enable_on_active_health_check"],
16285
+ ["probeIntervalMs", "probe_interval_ms"],
16286
+ ["probeTimeoutMs", "probe_timeout_ms"],
16287
+ ["slowStart", "slow_start"],
16288
+ ["durationMs", "duration_ms"],
16289
+ ["initialWeightFactor", "initial_weight_factor"],
16290
+ ["healthCheck", "health_check"],
16291
+ ["intervalMs", "interval_ms"],
16292
+ ["timeoutMs", "timeout_ms"],
16293
+ ["expectedStatus", "expected_status"],
16294
+ ["unhealthyThreshold", "unhealthy_threshold"],
16295
+ ["healthyThreshold", "healthy_threshold"],
16296
+ ["contentType", "content_type"],
16297
+ ["maxSize", "max_size"],
16298
+ ["retentionDays", "retention_days"],
16299
+ ["upstreams", "endpoints"]
16300
+ ];
16301
+ v2ToV3Migration = {
16302
+ fromVersion: 2,
16303
+ toVersion: 3,
16304
+ description: "Migrate camelCase to snake_case and extract upstreams into services",
16305
+ migrate(input) {
16306
+ const config = cloneJson(input);
16307
+ if (!isPlainRecord(config)) {
16308
+ throw new Error("Config must be an object");
16309
+ }
16310
+ const changes = [];
16311
+ const warnings = [];
16312
+ const renamedConfig = renameKeysRecursive(config, CAMEL_TO_SNAKE_MAPPINGS, changes);
16313
+ migrateRoutesToServiceRef(renamedConfig, changes, warnings);
16314
+ renamedConfig.config_version = 3;
16315
+ return {
16316
+ config: cleanupEmptyObjects(renamedConfig),
16317
+ changes,
16318
+ warnings
16319
+ };
16320
+ }
16321
+ };
16322
+ });
16323
+
16324
+ // src/config-migrations/versions/v3-to-v4.ts
16325
+ function serviceNameFromPath(path6) {
16326
+ const base = path6.replace(/^\/+/, "").replace(/\/+$/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
16327
+ return `${base || "root"}-service`;
16328
+ }
16329
+ function uniqueServiceName(preferredName, existingNames) {
16330
+ if (!existingNames.has(preferredName)) {
16331
+ existingNames.add(preferredName);
16332
+ return preferredName;
16333
+ }
16334
+ let suffix = 2;
16335
+ while (existingNames.has(`${preferredName}-${suffix}`)) {
16336
+ suffix++;
16337
+ }
16338
+ const name = `${preferredName}-${suffix}`;
16339
+ existingNames.add(name);
16340
+ return name;
16341
+ }
16342
+ function moveRouteConfigToService(route, service, routeIndex, changes) {
16343
+ if (route.failover && !service.failover) {
16344
+ service.failover = route.failover;
16345
+ changes.push({
16346
+ type: "move",
16347
+ path: `routes.${routeIndex}.failover`,
16348
+ from: `routes.${routeIndex}.failover`,
16349
+ to: `services.${service.name}.failover`,
16350
+ message: `Moved route failover to service "${service.name}"`
16351
+ });
16352
+ }
16353
+ if (route.sticky_session && !service.sticky_session) {
16354
+ service.sticky_session = route.sticky_session;
16355
+ changes.push({
16356
+ type: "move",
16357
+ path: `routes.${routeIndex}.sticky_session`,
16358
+ from: `routes.${routeIndex}.sticky_session`,
16359
+ to: `services.${service.name}.sticky_session`,
16360
+ message: `Moved route sticky_session to service "${service.name}"`
16361
+ });
16362
+ }
16363
+ if (route.failover) {
16364
+ delete route.failover;
16365
+ }
16366
+ if (route.sticky_session) {
16367
+ delete route.sticky_session;
16368
+ }
16369
+ }
16370
+ var v3ToV4Migration;
16371
+ var init_v3_to_v4 = __esm(() => {
16372
+ v3ToV4Migration = {
16373
+ fromVersion: 3,
16374
+ toVersion: 4,
16375
+ description: "Move failover and sticky_session from routes to services",
16376
+ migrate(input) {
16377
+ const config = cloneJson(input);
16378
+ if (!isPlainRecord(config)) {
16379
+ throw new Error("Config must be an object");
16380
+ }
16381
+ const changes = [];
16382
+ const warnings = [];
16383
+ const routes = Array.isArray(config.routes) ? config.routes : [];
16384
+ const services = Array.isArray(config.services) ? config.services : [];
16385
+ const serviceNames = new Set(services.map((service) => service.name));
16386
+ for (let routeIndex = 0;routeIndex < routes.length; routeIndex++) {
16387
+ const route = routes[routeIndex];
16388
+ if (!isPlainRecord(route)) {
16389
+ continue;
16390
+ }
16391
+ const hasRouteFailover = route.failover !== undefined;
16392
+ const hasRouteStickySession = route.sticky_session !== undefined;
16393
+ if (!hasRouteFailover && !hasRouteStickySession) {
16394
+ continue;
16395
+ }
16396
+ if (route.service) {
16397
+ let service2 = services.find((candidate) => candidate.name === route.service);
16398
+ if (!service2) {
16399
+ const routeEndpoints2 = route.endpoints ?? [];
16400
+ service2 = {
16401
+ name: route.service,
16402
+ endpoints: routeEndpoints2
16403
+ };
16404
+ services.push(service2);
16405
+ serviceNames.add(service2.name);
16406
+ changes.push({
16407
+ type: "move",
16408
+ path: `routes.${routeIndex}.service`,
16409
+ to: `services.${service2.name}`,
16410
+ message: `Created missing service "${service2.name}" for route "${route.path}"`
16411
+ });
16412
+ }
16413
+ moveRouteConfigToService(route, service2, routeIndex, changes);
16414
+ continue;
16415
+ }
16416
+ const serviceName = uniqueServiceName(serviceNameFromPath(route.path), serviceNames);
16417
+ const routeEndpoints = route.endpoints ?? [];
16418
+ const service = {
16419
+ name: serviceName,
16420
+ endpoints: routeEndpoints,
16421
+ ...route.failover && { failover: route.failover },
16422
+ ...route.sticky_session && { sticky_session: route.sticky_session }
16423
+ };
16424
+ services.push(service);
16425
+ route.service = serviceName;
16426
+ delete route.endpoints;
16427
+ changes.push({
16428
+ type: "move",
16429
+ path: `routes.${routeIndex}`,
16430
+ from: `routes.${routeIndex}.endpoints/failover/sticky_session`,
16431
+ to: `services.${serviceName}`,
16432
+ message: `Created service "${serviceName}" from route "${route.path}"`
16433
+ });
16434
+ moveRouteConfigToService(route, service, routeIndex, changes);
16435
+ }
16436
+ if (services.length > 0) {
16437
+ config.services = services;
16438
+ }
16439
+ config.config_version = 4;
16188
16440
  return {
16189
16441
  config: cleanupEmptyObjects(config),
16190
16442
  changes,
@@ -16198,8 +16450,12 @@ var init_v1_to_v2 = __esm(() => {
16198
16450
  var CONFIG_MIGRATIONS;
16199
16451
  var init_config_migrations = __esm(() => {
16200
16452
  init_v1_to_v2();
16453
+ init_v2_to_v3();
16454
+ init_v3_to_v4();
16201
16455
  CONFIG_MIGRATIONS = [
16202
- v1ToV2Migration
16456
+ v1ToV2Migration,
16457
+ v2ToV3Migration,
16458
+ v3ToV4Migration
16203
16459
  ];
16204
16460
  });
16205
16461
 
@@ -16228,7 +16484,7 @@ function migrateConfigToLatest(rawConfig) {
16228
16484
  if (!isPlainRecord(workingConfig)) {
16229
16485
  throw new Error("Migrated config must be an object");
16230
16486
  }
16231
- workingConfig.configVersion = LATEST_CONFIG_VERSION;
16487
+ workingConfig.config_version = LATEST_CONFIG_VERSION;
16232
16488
  return {
16233
16489
  config: workingConfig,
16234
16490
  originalVersion,
@@ -16251,105 +16507,119 @@ function ensurePositiveNumber(value, field, context) {
16251
16507
  }
16252
16508
  return value;
16253
16509
  }
16254
- function normalizeRouteConfig(route) {
16510
+ function resolveRouteEndpoints(route, services) {
16511
+ if (route.service) {
16512
+ const svc = services?.find((s) => s.name === route.service);
16513
+ if (!svc) {
16514
+ logger.error(`Route "${route.path}" references unknown service "${route.service}".`);
16515
+ process.exit(1);
16516
+ }
16517
+ return svc.endpoints;
16518
+ }
16519
+ const routeEndpoints = route.endpoints ?? [];
16520
+ if (routeEndpoints.length > 0) {
16521
+ return routeEndpoints;
16522
+ }
16523
+ if (route.direct_response?.enabled || route.redirect?.enabled || route.response_rules?.some((rule) => rule.enabled)) {
16524
+ return [];
16525
+ }
16526
+ logger.error(`Route "${route.path}" must have "endpoints", "service", "response_rules", "direct_response", or "redirect" defined.`);
16527
+ process.exit(1);
16528
+ }
16529
+ function normalizeRouteConfig(route, services) {
16255
16530
  route.timeouts ??= {};
16256
- route.failover ??= undefined;
16257
- for (const upstream of route.upstreams) {
16258
- if (upstream.weight === undefined) {
16259
- upstream.weight = 100;
16531
+ const endpoints = resolveRouteEndpoints(route, services);
16532
+ route.endpoints = endpoints;
16533
+ for (const endpoint of endpoints) {
16534
+ if (endpoint.weight === undefined) {
16535
+ endpoint.weight = 100;
16260
16536
  }
16261
- if (upstream.priority === undefined) {
16262
- upstream.priority = 1;
16537
+ if (endpoint.priority === undefined) {
16538
+ endpoint.priority = 1;
16263
16539
  }
16264
16540
  }
16265
16541
  return route;
16266
16542
  }
16267
- function validateStickySession(route) {
16268
- if (route.stickySession === undefined) {
16543
+ function validateStickySession(service) {
16544
+ if (service.sticky_session === undefined) {
16269
16545
  return;
16270
16546
  }
16271
- if (typeof route.stickySession !== "object" || route.stickySession === null || Array.isArray(route.stickySession)) {
16272
- logger.error(`Invalid stickySession config in route "${route.path}". stickySession must be an object.`);
16547
+ if (typeof service.sticky_session !== "object" || service.sticky_session === null || Array.isArray(service.sticky_session)) {
16548
+ logger.error(`Invalid sticky_session config in service "${service.name}". sticky_session must be an object.`);
16273
16549
  process.exit(1);
16274
16550
  }
16275
- if (route.stickySession.enabled !== undefined && typeof route.stickySession.enabled !== "boolean") {
16276
- logger.error(`Invalid stickySession.enabled in route "${route.path}". stickySession.enabled must be a boolean.`);
16551
+ if (service.sticky_session.enabled !== undefined && typeof service.sticky_session.enabled !== "boolean") {
16552
+ logger.error(`Invalid sticky_session.enabled in service "${service.name}". sticky_session.enabled must be a boolean.`);
16277
16553
  process.exit(1);
16278
16554
  }
16279
- if (route.stickySession.keyExpression !== undefined && typeof route.stickySession.keyExpression !== "string") {
16280
- logger.error(`Invalid stickySession.keyExpression in route "${route.path}". stickySession.keyExpression must be a string.`);
16555
+ if (service.sticky_session.key_expression !== undefined && typeof service.sticky_session.key_expression !== "string") {
16556
+ logger.error(`Invalid sticky_session.key_expression in service "${service.name}". sticky_session.key_expression must be a string.`);
16281
16557
  process.exit(1);
16282
16558
  }
16283
16559
  }
16284
- function validateFailoverConfig(route) {
16285
- const failover = route.failover;
16560
+ function validateFailoverConfig(service) {
16561
+ const failover = service.failover;
16286
16562
  if (!failover) {
16287
16563
  return;
16288
16564
  }
16289
16565
  if (typeof failover.enabled !== "boolean") {
16290
- logger.error(`Invalid failover.enabled in route "${route.path}". failover.enabled must be a boolean.`);
16566
+ logger.error(`Invalid failover.enabled in service "${service.name}". failover.enabled must be a boolean.`);
16291
16567
  process.exit(1);
16292
16568
  }
16293
- if (route.upstreams.length < 2 && failover.enabled) {
16294
- logger.warn(`Route for path "${route.path}" has failover enabled but less than 2 upstreams. Failover will not be active.`);
16569
+ if (service.endpoints.length < 2 && failover.enabled) {
16570
+ logger.warn(`Service "${service.name}" has failover enabled but less than 2 endpoints. Failover will not be active.`);
16295
16571
  }
16296
- if (route.timeouts?.connectMs !== undefined) {
16297
- ensurePositiveNumber(route.timeouts.connectMs, "timeouts.connectMs", `route "${route.path}"`);
16572
+ if (failover.recovery?.probe_interval_ms !== undefined) {
16573
+ ensurePositiveNumber(failover.recovery.probe_interval_ms, "failover.recovery.probe_interval_ms", `service "${service.name}"`);
16298
16574
  }
16299
- if (route.timeouts?.requestMs !== undefined) {
16300
- ensurePositiveNumber(route.timeouts.requestMs, "timeouts.requestMs", `route "${route.path}"`);
16575
+ if (failover.recovery?.probe_timeout_ms !== undefined) {
16576
+ ensurePositiveNumber(failover.recovery.probe_timeout_ms, "failover.recovery.probe_timeout_ms", `service "${service.name}"`);
16301
16577
  }
16302
- if (failover.recovery?.probeIntervalMs !== undefined) {
16303
- ensurePositiveNumber(failover.recovery.probeIntervalMs, "failover.recovery.probeIntervalMs", `route "${route.path}"`);
16578
+ if (failover.passive_health?.consecutive_failures !== undefined) {
16579
+ ensurePositiveNumber(failover.passive_health.consecutive_failures, "failover.passive_health.consecutive_failures", `service "${service.name}"`);
16304
16580
  }
16305
- if (failover.recovery?.probeTimeoutMs !== undefined) {
16306
- ensurePositiveNumber(failover.recovery.probeTimeoutMs, "failover.recovery.probeTimeoutMs", `route "${route.path}"`);
16581
+ if (failover.passive_health?.healthy_successes !== undefined) {
16582
+ ensurePositiveNumber(failover.passive_health.healthy_successes, "failover.passive_health.healthy_successes", `service "${service.name}"`);
16307
16583
  }
16308
- if (failover.passiveHealth?.consecutiveFailures !== undefined) {
16309
- ensurePositiveNumber(failover.passiveHealth.consecutiveFailures, "failover.passiveHealth.consecutiveFailures", `route "${route.path}"`);
16584
+ if (failover.passive_health?.auto_disable_threshold !== undefined) {
16585
+ ensurePositiveNumber(failover.passive_health.auto_disable_threshold, "failover.passive_health.auto_disable_threshold", `service "${service.name}"`);
16310
16586
  }
16311
- if (failover.passiveHealth?.healthySuccesses !== undefined) {
16312
- ensurePositiveNumber(failover.passiveHealth.healthySuccesses, "failover.passiveHealth.healthySuccesses", `route "${route.path}"`);
16313
- }
16314
- if (failover.passiveHealth?.autoDisableThreshold !== undefined) {
16315
- ensurePositiveNumber(failover.passiveHealth.autoDisableThreshold, "failover.passiveHealth.autoDisableThreshold", `route "${route.path}"`);
16316
- }
16317
- if (failover.passiveHealth?.autoEnableOnActiveHealthCheck !== undefined && typeof failover.passiveHealth.autoEnableOnActiveHealthCheck !== "boolean") {
16318
- logger.error(`Invalid failover.passiveHealth.autoEnableOnActiveHealthCheck in route "${route.path}". It must be a boolean.`);
16587
+ if (failover.passive_health?.auto_enable_on_active_health_check !== undefined && typeof failover.passive_health.auto_enable_on_active_health_check !== "boolean") {
16588
+ logger.error(`Invalid failover.passive_health.auto_enable_on_active_health_check in service "${service.name}". It must be a boolean.`);
16319
16589
  process.exit(1);
16320
16590
  }
16321
- if (failover.slowStart) {
16322
- if (typeof failover.slowStart.enabled !== "boolean") {
16323
- logger.error(`Invalid failover.slowStart.enabled in route "${route.path}". It must be a boolean.`);
16591
+ if (failover.slow_start) {
16592
+ if (typeof failover.slow_start.enabled !== "boolean") {
16593
+ logger.error(`Invalid failover.slow_start.enabled in service "${service.name}". It must be a boolean.`);
16324
16594
  process.exit(1);
16325
16595
  }
16326
- if (failover.slowStart.durationMs !== undefined) {
16327
- ensurePositiveNumber(failover.slowStart.durationMs, "failover.slowStart.durationMs", `route "${route.path}"`);
16596
+ if (failover.slow_start.duration_ms !== undefined) {
16597
+ ensurePositiveNumber(failover.slow_start.duration_ms, "failover.slow_start.duration_ms", `service "${service.name}"`);
16328
16598
  }
16329
- if (failover.slowStart.initialWeightFactor !== undefined) {
16330
- const factor = failover.slowStart.initialWeightFactor;
16599
+ if (failover.slow_start.initial_weight_factor !== undefined) {
16600
+ const factor = failover.slow_start.initial_weight_factor;
16331
16601
  if (typeof factor !== "number" || Number.isNaN(factor) || factor <= 0 || factor > 1) {
16332
- logger.error(`failover.slowStart.initialWeightFactor in route "${route.path}" must be between 0 and 1.`);
16602
+ logger.error(`failover.slow_start.initial_weight_factor in service "${service.name}" must be between 0 and 1.`);
16333
16603
  process.exit(1);
16334
16604
  }
16335
16605
  }
16336
16606
  }
16337
- if (failover.healthCheck) {
16338
- if (typeof failover.healthCheck.enabled !== "boolean") {
16339
- logger.error(`Invalid failover.healthCheck.enabled in route "${route.path}". It must be a boolean.`);
16607
+ if (failover.health_check) {
16608
+ if (typeof failover.health_check.enabled !== "boolean") {
16609
+ logger.error(`Invalid failover.health_check.enabled in service "${service.name}". It must be a boolean.`);
16340
16610
  process.exit(1);
16341
16611
  }
16342
- if (failover.healthCheck.intervalMs !== undefined) {
16343
- ensurePositiveNumber(failover.healthCheck.intervalMs, "failover.healthCheck.intervalMs", `route "${route.path}"`);
16612
+ if (failover.health_check.interval_ms !== undefined) {
16613
+ ensurePositiveNumber(failover.health_check.interval_ms, "failover.health_check.interval_ms", `service "${service.name}"`);
16344
16614
  }
16345
- if (failover.healthCheck.timeoutMs !== undefined) {
16346
- ensurePositiveNumber(failover.healthCheck.timeoutMs, "failover.healthCheck.timeoutMs", `route "${route.path}"`);
16615
+ if (failover.health_check.timeout_ms !== undefined) {
16616
+ ensurePositiveNumber(failover.health_check.timeout_ms, "failover.health_check.timeout_ms", `service "${service.name}"`);
16347
16617
  }
16348
- if (failover.healthCheck.unhealthyThreshold !== undefined) {
16349
- ensurePositiveNumber(failover.healthCheck.unhealthyThreshold, "failover.healthCheck.unhealthyThreshold", `route "${route.path}"`);
16618
+ if (failover.health_check.unhealthy_threshold !== undefined) {
16619
+ ensurePositiveNumber(failover.health_check.unhealthy_threshold, "failover.health_check.unhealthy_threshold", `service "${service.name}"`);
16350
16620
  }
16351
- if (failover.healthCheck.healthyThreshold !== undefined) {
16352
- ensurePositiveNumber(failover.healthCheck.healthyThreshold, "failover.healthCheck.healthyThreshold", `route "${route.path}"`);
16621
+ if (failover.health_check.healthy_threshold !== undefined) {
16622
+ ensurePositiveNumber(failover.health_check.healthy_threshold, "failover.health_check.healthy_threshold", `service "${service.name}"`);
16353
16623
  }
16354
16624
  }
16355
16625
  }
@@ -16377,44 +16647,92 @@ function validateAuthConfig(authConfig, context) {
16377
16647
  }
16378
16648
  logger.debug(`Auth config validated successfully for ${context}`);
16379
16649
  }
16650
+ function validateServices(services) {
16651
+ const names = new Set;
16652
+ for (const service of services) {
16653
+ if (!service.name || typeof service.name !== "string") {
16654
+ logger.error('Each service must have a non-empty "name" string.');
16655
+ process.exit(1);
16656
+ }
16657
+ if (names.has(service.name)) {
16658
+ logger.error(`Duplicate service name: "${service.name}". Service names must be unique.`);
16659
+ process.exit(1);
16660
+ }
16661
+ names.add(service.name);
16662
+ if (!service.endpoints || !Array.isArray(service.endpoints) || service.endpoints.length === 0) {
16663
+ logger.error(`Service "${service.name}" must have a non-empty "endpoints" array.`);
16664
+ process.exit(1);
16665
+ }
16666
+ for (const endpoint of service.endpoints) {
16667
+ if (typeof endpoint.target !== "string") {
16668
+ logger.error(`Invalid endpoint in service "${service.name}". Each endpoint must have a string "target".`);
16669
+ process.exit(1);
16670
+ }
16671
+ }
16672
+ validateStickySession(service);
16673
+ validateFailoverConfig(service);
16674
+ }
16675
+ }
16380
16676
  function validateAndNormalizeConfig(config) {
16381
16677
  if (!config.routes || !Array.isArray(config.routes)) {
16382
16678
  logger.error('Error: "routes" is not defined or not an array in config.json.');
16383
16679
  process.exit(1);
16384
16680
  }
16681
+ if (config.services && Array.isArray(config.services)) {
16682
+ validateServices(config.services);
16683
+ }
16385
16684
  if (config.auth) {
16386
16685
  validateAuthConfig(config.auth, "global");
16387
16686
  }
16388
16687
  for (const route of config.routes) {
16389
- if (!route.upstreams || route.upstreams.length === 0) {
16390
- logger.error(`Route for path "${route.path}" must have a non-empty "upstreams" array.`);
16688
+ const hasEndpoints = route.endpoints && route.endpoints.length > 0;
16689
+ const hasServiceRef = !!route.service;
16690
+ const hasDirectResponse = !!route.direct_response?.enabled;
16691
+ const hasRedirect = !!route.redirect?.enabled;
16692
+ const hasResponseRules = !!route.response_rules?.some((rule) => rule.enabled);
16693
+ if (!hasEndpoints && !hasServiceRef && !hasDirectResponse && !hasRedirect && !hasResponseRules) {
16694
+ logger.error(`Route for path "${route.path}" must have "endpoints", "service", "response_rules", "direct_response", or "redirect" defined.`);
16391
16695
  process.exit(1);
16392
16696
  }
16697
+ if (hasServiceRef && config.services) {
16698
+ const svc = config.services.find((s) => s.name === route.service);
16699
+ if (!svc) {
16700
+ logger.error(`Route "${route.path}" references unknown service "${route.service}".`);
16701
+ process.exit(1);
16702
+ }
16703
+ }
16393
16704
  if (route.auth) {
16394
16705
  validateAuthConfig(route.auth, `route "${route.path}"`);
16395
16706
  }
16396
- validateStickySession(route);
16397
- validateFailoverConfig(route);
16398
- normalizeRouteConfig(route);
16399
- let totalWeight = 0;
16400
- for (const upstream of route.upstreams) {
16401
- if (typeof upstream.target !== "string") {
16402
- logger.error(`Invalid upstream in route for path "${route.path}". Each upstream must have a string "target".`);
16403
- process.exit(1);
16404
- }
16405
- if (typeof upstream.weight !== "number" || upstream.weight <= 0) {
16406
- logger.error(`Invalid weight in route for path "${route.path}". Weight must be a positive number.`);
16407
- process.exit(1);
16707
+ normalizeRouteConfig(route, config.services);
16708
+ if (route.timeouts?.connect_ms !== undefined) {
16709
+ ensurePositiveNumber(route.timeouts.connect_ms, "timeouts.connect_ms", `route "${route.path}"`);
16710
+ }
16711
+ if (route.timeouts?.request_ms !== undefined) {
16712
+ ensurePositiveNumber(route.timeouts.request_ms, "timeouts.request_ms", `route "${route.path}"`);
16713
+ }
16714
+ const endpoints = resolveRouteEndpoints(route, config.services);
16715
+ if (endpoints.length > 0) {
16716
+ let total_weight = 0;
16717
+ for (const endpoint of endpoints) {
16718
+ if (typeof endpoint.target !== "string") {
16719
+ logger.error(`Invalid endpoint in route for path "${route.path}". Each endpoint must have a string "target".`);
16720
+ process.exit(1);
16721
+ }
16722
+ if (typeof endpoint.weight !== "number" || endpoint.weight <= 0) {
16723
+ logger.error(`Invalid weight in route for path "${route.path}". Weight must be a positive number.`);
16724
+ process.exit(1);
16725
+ }
16726
+ if (typeof endpoint.priority !== "number" || endpoint.priority <= 0) {
16727
+ logger.error(`Invalid priority in route for path "${route.path}". Priority must be a positive number.`);
16728
+ process.exit(1);
16729
+ }
16730
+ total_weight += endpoint.weight;
16408
16731
  }
16409
- if (typeof upstream.priority !== "number" || upstream.priority <= 0) {
16410
- logger.error(`Invalid priority in route for path "${route.path}". Priority must be a positive number.`);
16732
+ if (total_weight === 0) {
16733
+ logger.error(`Total weight for endpoints in route "${route.path}" cannot be zero.`);
16411
16734
  process.exit(1);
16412
16735
  }
16413
- totalWeight += upstream.weight;
16414
- }
16415
- if (totalWeight === 0) {
16416
- logger.error(`Total weight for upstreams in route "${route.path}" cannot be zero.`);
16417
- process.exit(1);
16418
16736
  }
16419
16737
  }
16420
16738
  return config;
@@ -16431,7 +16749,7 @@ function preloadGlobalConfig() {
16431
16749
  const config = migrateConfigToLatest(rawConfig).config;
16432
16750
  const configMapping = [
16433
16751
  {
16434
- jsonKey: "logLevel",
16752
+ jsonKey: "log_level",
16435
16753
  envKey: "LOG_LEVEL",
16436
16754
  default: "info",
16437
16755
  validate: (value) => ["trace", "debug", "info", "warn", "error", "fatal"].includes(value.toLowerCase())
@@ -16455,7 +16773,7 @@ function preloadGlobalConfig() {
16455
16773
  }
16456
16774
  },
16457
16775
  {
16458
- jsonKey: "bodyParserLimit",
16776
+ jsonKey: "body_parser_limit",
16459
16777
  envKey: "BODY_PARSER_LIMIT",
16460
16778
  default: "50mb"
16461
16779
  }
@@ -16486,7 +16804,7 @@ async function loadConfig(configPath) {
16486
16804
  if (!fs6.existsSync(configFilePath)) {
16487
16805
  logger.info(`Config file not found at ${configFilePath}, creating empty configuration`);
16488
16806
  const minimalConfig = {
16489
- configVersion: 2,
16807
+ config_version: 4,
16490
16808
  routes: []
16491
16809
  };
16492
16810
  fs6.writeFileSync(configFilePath, JSON.stringify(minimalConfig, null, 2), "utf-8");