@push.rocks/smartproxy 28.0.0 → 28.1.1

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 (28) hide show
  1. package/changelog.md +42 -0
  2. package/dist_rust/rustproxy_linux_amd64 +0 -0
  3. package/dist_rust/rustproxy_linux_amd64.tsrust-build.json +5 -5
  4. package/dist_rust/rustproxy_linux_arm64 +0 -0
  5. package/dist_rust/rustproxy_linux_arm64.tsrust-build.json +5 -5
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +4 -2
  8. package/dist_ts/proxies/smart-proxy/models/rust-types.d.ts +17 -0
  9. package/dist_ts/proxies/smart-proxy/models/rust-types.js +3 -2
  10. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.d.ts +28 -4
  11. package/dist_ts/proxies/smart-proxy/rust-proxy-bridge.js +75 -6
  12. package/dist_ts/proxies/smart-proxy/smart-proxy.js +45 -40
  13. package/dist_ts/proxies/smart-proxy/utils/index.d.ts +2 -0
  14. package/dist_ts/proxies/smart-proxy/utils/index.js +3 -1
  15. package/dist_ts/proxies/smart-proxy/utils/route-validation-error.d.ts +24 -0
  16. package/dist_ts/proxies/smart-proxy/utils/route-validation-error.js +41 -0
  17. package/dist_ts/proxies/smart-proxy/utils/route-validator.d.ts +12 -2
  18. package/dist_ts/proxies/smart-proxy/utils/route-validator.js +56 -41
  19. package/package.json +9 -3
  20. package/readme.md +32 -3
  21. package/ts/00_commitinfo_data.ts +1 -1
  22. package/ts/proxies/smart-proxy/models/interfaces.ts +4 -2
  23. package/ts/proxies/smart-proxy/models/rust-types.ts +20 -0
  24. package/ts/proxies/smart-proxy/rust-proxy-bridge.ts +106 -13
  25. package/ts/proxies/smart-proxy/smart-proxy.ts +40 -39
  26. package/ts/proxies/smart-proxy/utils/index.ts +4 -0
  27. package/ts/proxies/smart-proxy/utils/route-validation-error.ts +60 -0
  28. package/ts/proxies/smart-proxy/utils/route-validator.ts +74 -49
@@ -1,14 +1,17 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { logger } from '../../core/utils/logger.js';
3
- import type {
4
- IRustCertificateStatus,
5
- IRustChallengeOptions,
6
- IRustMetricsSnapshot,
7
- IRustProxyOptions,
8
- IRustMetricsHistory,
9
- IRustRouteConfig,
10
- IRustStatistics,
3
+ import {
4
+ RUST_ROUTE_VALIDATION_ERROR_CODE,
5
+ type IRustCertificateStatus,
6
+ type IRustChallengeOptions,
7
+ type IRustMetricsSnapshot,
8
+ type IRustProxyOptions,
9
+ type IRustMetricsHistory,
10
+ type IRustRouteConfig,
11
+ type IRustRouteValidationErrorData,
12
+ type IRustStatistics,
11
13
  } from './models/rust-types.js';
14
+ import { RouteValidationError } from './utils/route-validation-error.js';
12
15
  import type { IActiveConnectionSnapshot, IActiveConnectionSnapshotOptions, IActiveConnectionSnapshotPage, ISmartProxySecurityPolicy } from './models/interfaces.js';
13
16
 
14
17
  const MAXIMUM_RUST_CONNECTION_ID = 18_446_744_073_709_551_615n;
@@ -26,6 +29,69 @@ function validateActiveConnectionSnapshotOptions(options: IActiveConnectionSnaps
26
29
  }
27
30
  }
28
31
 
32
+ function isRustRouteValidationErrorData(
33
+ data: unknown,
34
+ sentRouteCount: number,
35
+ ): data is IRustRouteValidationErrorData {
36
+ if (
37
+ typeof data !== 'object'
38
+ || data === null
39
+ || !('routes' in data)
40
+ || !Array.isArray(data.routes)
41
+ || data.routes.length === 0
42
+ ) {
43
+ return false;
44
+ }
45
+ return data.routes.every((issue: unknown) =>
46
+ typeof issue === 'object'
47
+ && issue !== null
48
+ && 'index' in issue
49
+ && typeof issue.index === 'number'
50
+ && Number.isSafeInteger(issue.index)
51
+ && issue.index >= 0
52
+ && issue.index < sentRouteCount
53
+ && 'route' in issue
54
+ && typeof issue.route === 'string'
55
+ && 'messages' in issue
56
+ && Array.isArray(issue.messages)
57
+ && issue.messages.every((message: unknown) => typeof message === 'string'),
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Turn a Rust `ROUTE_VALIDATION` refusal into a `RouteValidationError` carrying the
63
+ * per-route reasons from its `errorData`. Any other error is returned unchanged.
64
+ *
65
+ * `routeLabels` holds `RouteValidator.getRouteLabels()` of the caller's routes, one per sent
66
+ * route. Entries are relabelled by index: the preprocessor names unnamed routes for Rust, so
67
+ * the engine's own labels can differ from the ones the caller sees.
68
+ */
69
+ export function toRouteValidationError(error: unknown, routeLabels: string[]): unknown {
70
+ if (
71
+ !(error instanceof plugins.smartrust.RustBridgeRequestError)
72
+ || error.responseErrorCode !== RUST_ROUTE_VALIDATION_ERROR_CODE
73
+ ) {
74
+ return error;
75
+ }
76
+ if (!isRustRouteValidationErrorData(error.responseErrorData, routeLabels.length)) {
77
+ return new Error(`RustProxy sent a route validation refusal with malformed errorData: ${error.message}`, {
78
+ cause: error,
79
+ });
80
+ }
81
+ return new RouteValidationError(
82
+ 'Route validation failed',
83
+ error.responseErrorData.routes.map(({ index, messages }) => ({ index, route: routeLabels[index], messages })),
84
+ );
85
+ }
86
+
87
+ function assertOneLabelPerRoute(routes: IRustRouteConfig[], routeLabels: string[]): void {
88
+ if (routes.length !== routeLabels.length) {
89
+ throw new Error(
90
+ `RustProxyBridge got ${routeLabels.length} route label(s) for ${routes.length} route(s); they must match one to one`,
91
+ );
92
+ }
93
+ }
94
+
29
95
  /**
30
96
  * Type-safe command definitions for the Rust proxy IPC protocol.
31
97
  */
@@ -97,6 +163,14 @@ function buildLocalPaths(): string[] {
97
163
  return paths;
98
164
  }
99
165
 
166
+ export interface IRustProxyBridgeOptions {
167
+ /**
168
+ * Explicit path to the RustProxy binary. When set, it is the only candidate:
169
+ * `spawn()` rejects with a `RustBinaryLocatorError` if it is missing or not executable.
170
+ */
171
+ binaryPath?: string;
172
+ }
173
+
100
174
  /**
101
175
  * Bridge between TypeScript SmartProxy and the Rust binary.
102
176
  * Wraps @push.rocks/smartrust's RustBridge with type-safe command definitions.
@@ -104,11 +178,12 @@ function buildLocalPaths(): string[] {
104
178
  export class RustProxyBridge extends plugins.EventEmitter {
105
179
  private bridge: plugins.smartrust.RustBridge<TSmartProxyCommands>;
106
180
 
107
- constructor() {
181
+ constructor(options: IRustProxyBridgeOptions = {}) {
108
182
  super();
109
183
 
110
184
  this.bridge = new plugins.smartrust.RustBridge<TSmartProxyCommands>({
111
185
  binaryName: 'rustproxy',
186
+ binaryPath: options.binaryPath,
112
187
  envVarName: 'SMARTPROXY_RUST_BINARY',
113
188
  platformPackagePrefix: '@push.rocks/smartproxy',
114
189
  localPaths: buildLocalPaths(),
@@ -155,16 +230,34 @@ export class RustProxyBridge extends plugins.EventEmitter {
155
230
 
156
231
  // --- Convenience methods for each management command ---
157
232
 
158
- public async startProxy(config: IRustProxyOptions): Promise<void> {
159
- await this.bridge.sendCommand('start', { config });
233
+ /**
234
+ * @param routeLabels `RouteValidator.getRouteLabels()` of the routes before preprocessing,
235
+ * used to label a route validation refusal.
236
+ */
237
+ public async startProxy(config: IRustProxyOptions, routeLabels: string[]): Promise<void> {
238
+ assertOneLabelPerRoute(config.routes, routeLabels);
239
+ try {
240
+ await this.bridge.sendCommand('start', { config });
241
+ } catch (error) {
242
+ throw toRouteValidationError(error, routeLabels);
243
+ }
160
244
  }
161
245
 
162
246
  public async stopProxy(): Promise<void> {
163
247
  await this.bridge.sendCommand('stop', {} as Record<string, never>);
164
248
  }
165
249
 
166
- public async updateRoutes(routes: IRustRouteConfig[]): Promise<void> {
167
- await this.bridge.sendCommand('updateRoutes', { routes });
250
+ /**
251
+ * @param routeLabels `RouteValidator.getRouteLabels()` of the routes before preprocessing,
252
+ * used to label a route validation refusal.
253
+ */
254
+ public async updateRoutes(routes: IRustRouteConfig[], routeLabels: string[]): Promise<void> {
255
+ assertOneLabelPerRoute(routes, routeLabels);
256
+ try {
257
+ await this.bridge.sendCommand('updateRoutes', { routes });
258
+ } catch (error) {
259
+ throw toRouteValidationError(error, routeLabels);
260
+ }
168
261
  }
169
262
 
170
263
  public async setSecurityPolicy(policy: ISmartProxySecurityPolicy): Promise<void> {
@@ -12,6 +12,7 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js';
12
12
  // Route management
13
13
  import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
14
14
  import { RouteValidator } from './utils/route-validator.js';
15
+ import { RouteValidationError, type IRouteValidationErrorEntry } from './utils/route-validation-error.js';
15
16
  import { buildRustProxyOptions } from './utils/rust-config.js';
16
17
  import { generateDefaultCertificate } from './utils/default-cert-generator.js';
17
18
  import { Mutex } from './utils/mutex.js';
@@ -107,7 +108,7 @@ export class SmartProxy extends plugins.EventEmitter {
107
108
  const validation = RouteValidator.validateRoutes(this.settings.routes);
108
109
  if (!validation.valid) {
109
110
  RouteValidator.logValidationErrors(validation.errors);
110
- throw new Error(`Initial route validation failed: ${validation.errors.size} route(s) have errors`);
111
+ throw new RouteValidationError('Initial route validation failed', validation.entries);
111
112
  }
112
113
  }
113
114
 
@@ -126,7 +127,7 @@ export class SmartProxy extends plugins.EventEmitter {
126
127
  routes: this.settings.routes,
127
128
  });
128
129
 
129
- this.bridge = new RustProxyBridge();
130
+ this.bridge = new RustProxyBridge({ binaryPath: this.settings.rustBinaryPath });
130
131
  this.preprocessor = new RoutePreprocessor();
131
132
  this.metricsAdapter = new RustMetricsAdapter(
132
133
  this.bridge,
@@ -216,7 +217,7 @@ export class SmartProxy extends plugins.EventEmitter {
216
217
  const config = this.buildRustConfig(rustRoutes, acmeForRust);
217
218
 
218
219
  // Start the Rust proxy
219
- await this.bridge.startProxy(config);
220
+ await this.bridge.startProxy(config, RouteValidator.getRouteLabels(this.settings.routes));
220
221
 
221
222
  // Now that Rust proxy is running, configure socket handler relay
222
223
  if (this.socketHandlerServer) {
@@ -370,7 +371,7 @@ export class SmartProxy extends plugins.EventEmitter {
370
371
  const validation = RouteValidator.validateRoutes(newRoutes);
371
372
  if (!validation.valid) {
372
373
  RouteValidator.logValidationErrors(validation.errors);
373
- throw new Error(`Route validation failed: ${validation.errors.size} route(s) have errors`);
374
+ throw new RouteValidationError('Route validation failed', validation.entries);
374
375
  }
375
376
 
376
377
  const hasChallengeRoutes = this.hasChallengeRoutes(newRoutes);
@@ -389,7 +390,7 @@ export class SmartProxy extends plugins.EventEmitter {
389
390
  }
390
391
 
391
392
  // Send to Rust
392
- await this.bridge.updateRoutes(rustRoutes);
393
+ await this.bridge.updateRoutes(rustRoutes, RouteValidator.getRouteLabels(newRoutes));
393
394
  } catch (err) {
394
395
  if (hasChallengeRoutes && !challengeRelayWasStarted && this.challengeProviderRelayServer) {
395
396
  await this.bridge.setChallengeProviderRelay('').catch(() => undefined);
@@ -729,10 +730,11 @@ export class SmartProxy extends plugins.EventEmitter {
729
730
 
730
731
  private async validateChallengeRoutes(routes: IRouteConfig[]): Promise<void> {
731
732
  const manifestCache = new Map<string, plugins.smartchallenge.IChallengeProviderManifest>();
732
- const errors: string[] = [];
733
+ const labels = RouteValidator.getRouteLabels(routes);
734
+ const entries: IRouteValidationErrorEntry[] = [];
733
735
 
734
- for (const route of routes) {
735
- const routeName = route.name || route.id || 'unnamed route';
736
+ for (const [index, route] of routes.entries()) {
737
+ const errors: string[] = [];
736
738
  const challengeEntries: Array<{
737
739
  path: string;
738
740
  challenge: plugins.smartchallenge.IRouteChallengeConfig;
@@ -758,50 +760,50 @@ export class SmartProxy extends plugins.EventEmitter {
758
760
  if (challengeEntries.length === 0) continue;
759
761
 
760
762
  if (!route.name && !route.id) {
761
- errors.push(`${routeName}: challenge routes must set a stable route name or id`);
763
+ errors.push('challenge routes must set a stable route name or id');
762
764
  }
763
765
  if (route.action.type !== 'forward') {
764
- errors.push(`${routeName}: challenge routes must use forward actions`);
766
+ errors.push('challenge routes must use forward actions');
765
767
  }
766
768
  if (route.action.forwardingEngine === 'nftables') {
767
- errors.push(`${routeName}: challenge routes cannot use nftables forwarding`);
769
+ errors.push('challenge routes cannot use nftables forwarding');
768
770
  }
769
771
  if (route.action.tls?.mode === 'passthrough') {
770
- errors.push(`${routeName}: challenge routes cannot use TLS passthrough`);
772
+ errors.push('challenge routes cannot use TLS passthrough');
771
773
  }
772
774
  for (const target of route.action.targets || []) {
773
775
  if (typeof target.host === 'function' || typeof target.port === 'function') {
774
- errors.push(`${routeName}: challenge routes cannot use dynamic target host or port functions`);
776
+ errors.push('challenge routes cannot use dynamic target host or port functions');
775
777
  }
776
778
  if (target.tls?.mode === 'passthrough') {
777
- errors.push(`${routeName}: challenge routes cannot use target TLS passthrough`);
779
+ errors.push('challenge routes cannot use target TLS passthrough');
778
780
  }
779
781
  }
780
782
  const hasHttp3Transport = route.match.transport === 'all'
781
783
  && route.action.udp?.quic?.enableHttp3 === true;
782
784
  if (route.match.transport === 'udp') {
783
- errors.push(`${routeName}: challenge routes cannot use pure UDP transport`);
785
+ errors.push('challenge routes cannot use pure UDP transport');
784
786
  }
785
787
  if (route.match.transport === 'all' && !hasHttp3Transport) {
786
- errors.push(`${routeName}: challenge routes using transport 'all' must enable HTTP/3 with action.udp.quic.enableHttp3`);
788
+ errors.push(`challenge routes using transport 'all' must enable HTTP/3 with action.udp.quic.enableHttp3`);
787
789
  }
788
790
  if (route.match.protocol !== 'http') {
789
- errors.push(`${routeName}: challenge routes must set match.protocol to 'http'`);
791
+ errors.push(`challenge routes must set match.protocol to 'http'`);
790
792
  }
791
793
 
792
794
  for (const { path, challenge } of challengeEntries) {
793
795
  if (!challenge.providerId || typeof challenge.providerId !== 'string') {
794
- errors.push(`${routeName}: ${path}.providerId must be a non-empty string`);
796
+ errors.push(`${path}.providerId must be a non-empty string`);
795
797
  }
796
798
  if (!challenge.challengeType || typeof challenge.challengeType !== 'string') {
797
- errors.push(`${routeName}: ${path}.challengeType must be a non-empty string`);
799
+ errors.push(`${path}.challengeType must be a non-empty string`);
798
800
  }
799
- this.validateChallengeIntentShape(challenge, errors, routeName, path);
800
- this.collectForbiddenChallengeKeys(challenge, path, errors, routeName);
801
+ this.validateChallengeIntentShape(challenge, errors, path);
802
+ this.collectForbiddenChallengeKeys(challenge, path, errors);
801
803
 
802
804
  const provider = this.challengeProviders.get(challenge.providerId);
803
805
  if (!provider) {
804
- errors.push(`${routeName}: challenge provider '${challenge.providerId}' is not registered`);
806
+ errors.push(`challenge provider '${challenge.providerId}' is not registered`);
805
807
  continue;
806
808
  }
807
809
  let manifest = manifestCache.get(challenge.providerId);
@@ -810,24 +812,27 @@ export class SmartProxy extends plugins.EventEmitter {
810
812
  manifestCache.set(challenge.providerId, manifest);
811
813
  }
812
814
  if (!manifest.challengeTypes.some((type) => type.challengeType === challenge.challengeType)) {
813
- errors.push(`${routeName}: challenge provider '${challenge.providerId}' does not support type '${challenge.challengeType}'`);
815
+ errors.push(`challenge provider '${challenge.providerId}' does not support type '${challenge.challengeType}'`);
814
816
  }
815
817
  }
818
+
819
+ if (errors.length > 0) {
820
+ entries.push({ index, route: labels[index], messages: errors });
821
+ }
816
822
  }
817
823
 
818
- if (errors.length > 0) {
819
- throw new Error(`Challenge route validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
824
+ if (entries.length > 0) {
825
+ throw new RouteValidationError('Challenge route validation failed', entries);
820
826
  }
821
827
  }
822
828
 
823
829
  private validateChallengeIntentShape(
824
830
  challengeArg: unknown,
825
831
  errors: string[],
826
- routeName: string,
827
832
  challengePath = 'security.challenge',
828
833
  ): void {
829
834
  if (!this.isPlainRecord(challengeArg)) {
830
- errors.push(`${routeName}: ${challengePath} must be an object`);
835
+ errors.push(`${challengePath} must be an object`);
831
836
  return;
832
837
  }
833
838
 
@@ -836,25 +841,23 @@ export class SmartProxy extends plugins.EventEmitter {
836
841
  challengePath,
837
842
  new Set(['providerId', 'challengeType', 'policyRef', 'settings', 'applyTo', 'clearance']),
838
843
  errors,
839
- routeName,
840
844
  );
841
845
 
842
846
  const settings = challengeArg.settings;
843
847
  if (settings !== undefined && !this.isPlainRecord(settings)) {
844
- errors.push(`${routeName}: ${challengePath}.settings must be an object when set`);
848
+ errors.push(`${challengePath}.settings must be an object when set`);
845
849
  }
846
850
 
847
851
  const applyTo = challengeArg.applyTo;
848
852
  if (applyTo !== undefined) {
849
853
  if (!this.isPlainRecord(applyTo)) {
850
- errors.push(`${routeName}: ${challengePath}.applyTo must be an object when set`);
854
+ errors.push(`${challengePath}.applyTo must be an object when set`);
851
855
  } else {
852
856
  this.validateAllowedChallengeKeys(
853
857
  applyTo,
854
858
  `${challengePath}.applyTo`,
855
859
  new Set(['methods', 'browserNavigationsOnly', 'includePaths', 'excludePaths']),
856
860
  errors,
857
- routeName,
858
861
  );
859
862
  }
860
863
  }
@@ -862,14 +865,13 @@ export class SmartProxy extends plugins.EventEmitter {
862
865
  const clearance = challengeArg.clearance;
863
866
  if (clearance !== undefined) {
864
867
  if (!this.isPlainRecord(clearance)) {
865
- errors.push(`${routeName}: ${challengePath}.clearance must be an object when set`);
868
+ errors.push(`${challengePath}.clearance must be an object when set`);
866
869
  } else {
867
870
  this.validateAllowedChallengeKeys(
868
871
  clearance,
869
872
  `${challengePath}.clearance`,
870
873
  new Set(['ttlSeconds', 'bindToHost', 'bindToRoute', 'bindToIp']),
871
874
  errors,
872
- routeName,
873
875
  );
874
876
  }
875
877
  }
@@ -880,11 +882,10 @@ export class SmartProxy extends plugins.EventEmitter {
880
882
  pathArg: string,
881
883
  allowedKeysArg: Set<string>,
882
884
  errors: string[],
883
- routeName: string,
884
885
  ): void {
885
886
  for (const key of Object.keys(valueArg)) {
886
887
  if (!allowedKeysArg.has(key)) {
887
- errors.push(`${routeName}: ${pathArg}.${key} is not part of the persisted challenge intent shape`);
888
+ errors.push(`${pathArg}.${key} is not part of the persisted challenge intent shape`);
888
889
  }
889
890
  }
890
891
  }
@@ -895,19 +896,19 @@ export class SmartProxy extends plugins.EventEmitter {
895
896
  && !Array.isArray(valueArg);
896
897
  }
897
898
 
898
- private collectForbiddenChallengeKeys(value: unknown, path: string, errors: string[], routeName: string): void {
899
+ private collectForbiddenChallengeKeys(value: unknown, path: string, errors: string[]): void {
899
900
  if (!value || typeof value !== 'object') return;
900
901
  const forbiddenKeyPattern = /(endpoint|url|uri|host|hostname|address|ipAddress|port|secret|token|password|credential|apiKey|apikey|accessKey|privateKey|keyfile|certfile|deployment|container|socketPath)/i;
901
902
  for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
902
903
  const childPath = `${path}.${key}`;
903
904
  if (forbiddenKeyPattern.test(key) && !['providerId', 'challengeType', 'policyRef', 'bindToHost', 'bindToIp', 'includePaths', 'excludePaths'].includes(key)) {
904
- errors.push(`${routeName}: ${childPath} looks like runtime wiring or secret material and must not be stored in route config`);
905
+ errors.push(`${childPath} looks like runtime wiring or secret material and must not be stored in route config`);
905
906
  }
906
907
  if (typeof nestedValue === 'string' && this.looksLikeRuntimeChallengeValue(nestedValue) && !['includePaths', 'excludePaths'].includes(key)) {
907
- errors.push(`${routeName}: ${childPath} contains endpoint/IP-style runtime wiring and must not be stored in route config`);
908
+ errors.push(`${childPath} contains endpoint/IP-style runtime wiring and must not be stored in route config`);
908
909
  }
909
910
  if (nestedValue && typeof nestedValue === 'object') {
910
- this.collectForbiddenChallengeKeys(nestedValue, childPath, errors, routeName);
911
+ this.collectForbiddenChallengeKeys(nestedValue, childPath, errors);
911
912
  }
912
913
  }
913
914
  }
@@ -8,6 +8,10 @@
8
8
  // Export route validator (class-based and functional API)
9
9
  export * from './route-validator.js';
10
10
 
11
+ // Export the error SmartProxy throws when it refuses a route set
12
+ export { RouteValidationError } from './route-validation-error.js';
13
+ export type { IRouteValidationErrorEntry } from './route-validation-error.js';
14
+
11
15
  // Export route utilities for route operations
12
16
  export * from './route-utils.js';
13
17
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The validation failures of one route, as the route validators report them.
3
+ */
4
+ export interface IRouteValidationErrorEntry {
5
+ /** The route's position in the validated route list. */
6
+ index: number;
7
+ /**
8
+ * The route's label from `RouteValidator.getRouteLabels()`: its name, else its id,
9
+ * else `route[<index>]`; a name or id that several routes share gets `[<index>]` appended.
10
+ */
11
+ route: string;
12
+ /** Every reason the route was rejected. */
13
+ messages: string[];
14
+ }
15
+
16
+ const MAX_ROUTES_IN_MESSAGE = 10;
17
+ const MAX_MESSAGES_PER_ROUTE_IN_MESSAGE = 5;
18
+ const MAX_TEXT_LENGTH_IN_MESSAGE = 200;
19
+
20
+ function truncateText(textArg: string): string {
21
+ return textArg.length > MAX_TEXT_LENGTH_IN_MESSAGE
22
+ ? `${textArg.slice(0, MAX_TEXT_LENGTH_IN_MESSAGE)}…`
23
+ : textArg;
24
+ }
25
+
26
+ function formatEntry(entryArg: IRouteValidationErrorEntry): string {
27
+ const shownMessages = entryArg.messages
28
+ .slice(0, MAX_MESSAGES_PER_ROUTE_IN_MESSAGE)
29
+ .map(truncateText);
30
+ const hiddenMessageCount = entryArg.messages.length - shownMessages.length;
31
+ if (hiddenMessageCount > 0) {
32
+ shownMessages.push(`and ${hiddenMessageCount} more`);
33
+ }
34
+ return `route '${truncateText(entryArg.route)}': ${shownMessages.join('; ')}`;
35
+ }
36
+
37
+ function formatMessage(summaryArg: string, errorsArg: IRouteValidationErrorEntry[]): string {
38
+ const shownRoutes = errorsArg.slice(0, MAX_ROUTES_IN_MESSAGE).map(formatEntry);
39
+ const hiddenRouteCount = errorsArg.length - shownRoutes.length;
40
+ if (hiddenRouteCount > 0) {
41
+ shownRoutes.push(`and ${hiddenRouteCount} more route(s)`);
42
+ }
43
+ return `${summaryArg}: ${errorsArg.length} route(s) have errors: ${shownRoutes.join(' | ')}`;
44
+ }
45
+
46
+ /**
47
+ * Thrown when SmartProxy refuses a route set because it failed validation.
48
+ *
49
+ * `errors` lists every rejected route with all its reasons. The message names the
50
+ * first routes and reasons only, so a large route set cannot produce an unbounded message.
51
+ */
52
+ export class RouteValidationError extends Error {
53
+ public readonly errors: IRouteValidationErrorEntry[];
54
+
55
+ constructor(summaryArg: string, errorsArg: IRouteValidationErrorEntry[]) {
56
+ super(formatMessage(summaryArg, errorsArg));
57
+ this.name = 'RouteValidationError';
58
+ this.errors = errorsArg;
59
+ }
60
+ }