@suss/ir-core 0.34.0 → 0.34.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.
@@ -514,6 +514,38 @@ function pathAfterOrigin(text) {
514
514
  function statesAnOrigin(text) {
515
515
  return SCHEME_ORIGIN.test(text) || PROTOCOL_RELATIVE_ORIGIN.test(text);
516
516
  }
517
+ var PRIVATE_SUFFIXES = [
518
+ "local",
519
+ "internal",
520
+ "localhost",
521
+ "lan",
522
+ "home.arpa",
523
+ "svc",
524
+ "test",
525
+ "example",
526
+ "invalid",
527
+ "example.com",
528
+ "example.net",
529
+ "example.org"
530
+ ];
531
+ var PRIVATE_IPV4 = /^(?:127\.|10\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.|0\.0\.0\.0$)/;
532
+ function hostnameOf(host) {
533
+ const lower = host.toLowerCase();
534
+ const name = lower.startsWith("[") ? lower.slice(0, lower.indexOf("]") + 1) : lower.split(":")[0] ?? lower;
535
+ return name.replace(/\.$/, "");
536
+ }
537
+ function hostCanBeOwn(host) {
538
+ const name = hostnameOf(host);
539
+ if (name === "[::1]" || /^\[f[cd]/.test(name) || PRIVATE_IPV4.test(name)) {
540
+ return true;
541
+ }
542
+ if (!name.includes(".")) {
543
+ return true;
544
+ }
545
+ return PRIVATE_SUFFIXES.some(
546
+ (suffix) => name === suffix || name.endsWith(`.${suffix}`)
547
+ );
548
+ }
517
549
 
518
550
  // src/semantics/pathPattern.ts
519
551
  var HOLE_RANGE_SUFFIX = {
@@ -580,14 +612,18 @@ function shapedItem(segment) {
580
612
  return ".*";
581
613
  }
582
614
  if (part.startsWith("{")) {
583
- return "[^/]+";
615
+ return /[?*]\}$/.test(part) ? "[^/]*" : "[^/]+";
584
616
  }
585
617
  return escapedForRegex(part);
586
618
  }).join("");
619
+ const holes = segment.match(/\{[^}]*\}|\*/g) ?? [];
620
+ const text = segment.replace(/\{[^}]*\}|\*/g, "");
587
621
  return {
588
622
  kind: "shaped",
589
623
  shape: segment.replace(/\{[^}]*\}/g, "{}"),
590
- test: new RegExp(`^${source}$`)
624
+ test: new RegExp(`^${source}$`),
625
+ bare: holes.every((hole) => hole === "*" || /[?*]\}$/.test(hole)) ? text : null,
626
+ textLength: text.length
591
627
  };
592
628
  }
593
629
  function itemsOf(segment) {
@@ -607,10 +643,35 @@ function segmentsOf(alternative) {
607
643
  const trimmed = alternative.startsWith("/") ? alternative.slice(1) : alternative;
608
644
  return trimmed === "" ? [] : trimmed.split("/");
609
645
  }
646
+ function patternSegmentsOf(alternative) {
647
+ const segments = segmentsOf(alternative);
648
+ const first = segments[0];
649
+ const opensWithBaseUrl = !alternative.startsWith("/") && first !== void 0 && HOLE_SEGMENT.test(first);
650
+ return opensWithBaseUrl ? segments.slice(1) : segments;
651
+ }
652
+ function pathAfterBaseUrl(path) {
653
+ const segments = segmentsOf(path);
654
+ const first = segments[0];
655
+ if (path.startsWith("/") || first === void 0 || segments.length < 2 || !HOLE_SEGMENT.test(first)) {
656
+ return path;
657
+ }
658
+ return `/${segments.slice(1).join("/")}`;
659
+ }
660
+ var PATTERNS = /* @__PURE__ */ new Map();
661
+ var PATTERN_CACHE_LIMIT = 5e4;
610
662
  function patternOf(path) {
611
- return alternativesOf(path).map(
612
- (alternative) => segmentsOf(alternative).flatMap(itemsOf)
663
+ const known = PATTERNS.get(path);
664
+ if (known !== void 0) {
665
+ return known;
666
+ }
667
+ const pattern = alternativesOf(path).map(
668
+ (alternative) => patternSegmentsOf(alternative).flatMap(itemsOf)
613
669
  );
670
+ if (PATTERNS.size >= PATTERN_CACHE_LIMIT) {
671
+ PATTERNS.clear();
672
+ }
673
+ PATTERNS.set(path, pattern);
674
+ return pattern;
614
675
  }
615
676
  function literalItemsOf(path) {
616
677
  return segmentsOf(path).map((text) => ({ kind: "text", text }));
@@ -689,17 +750,104 @@ function pathSpansShapes(path) {
689
750
  function countOf(items, kinds) {
690
751
  return items.filter((item) => kinds.includes(item.kind)).length;
691
752
  }
692
- function pathSpecificity(path) {
693
- const alternatives = patternOf(path);
694
- const ranks = alternatives.map((items) => [
753
+ function readingSpecificity(items) {
754
+ return [
695
755
  countOf(items, ["text"]),
696
756
  countOf(items, ["shaped"]),
697
757
  -countOf(items, ["optional", "star"])
698
- ]);
699
- const loosest = ranks.reduce(
700
- (low, rank) => compareRanks(rank, low) < 0 ? rank : low
758
+ ];
759
+ }
760
+ function segmentFit(route, request) {
761
+ if (!meetOnOneSegment(route, request)) {
762
+ return "apart";
763
+ }
764
+ if (request.kind === "text") {
765
+ if (route.kind === "text") {
766
+ return "stated";
767
+ }
768
+ return route.kind === "shaped" ? "shaped" : "free";
769
+ }
770
+ if (absorbsAnything(route)) {
771
+ return "free";
772
+ }
773
+ if (request.kind !== "shaped") {
774
+ return "guess";
775
+ }
776
+ if (route.kind === "shaped") {
777
+ return "free";
778
+ }
779
+ return route.kind === "text" && request.bare === route.text ? "stated" : "guess";
780
+ }
781
+ function shapedText(item) {
782
+ return item.kind === "shaped" ? item.textLength : 0;
783
+ }
784
+ function betterFit(a, b) {
785
+ return a === void 0 || compareRanks(b, a) > 0;
786
+ }
787
+ function bestFit(route, request) {
788
+ const width = request.length + 1;
789
+ const best = new Array((route.length + 1) * width);
790
+ best[0] = [0, 0];
791
+ const offer = (i, j, fit) => {
792
+ const at = i * width + j;
793
+ if (betterFit(best[at], fit)) {
794
+ best[at] = fit;
795
+ }
796
+ };
797
+ for (let reached = 0; reached <= route.length + request.length; reached += 1) {
798
+ for (let i = Math.max(0, reached - request.length); i <= Math.min(route.length, reached); i += 1) {
799
+ const j = reached - i;
800
+ const fit = best[i * width + j];
801
+ if (fit === void 0) {
802
+ continue;
803
+ }
804
+ const left = route[i];
805
+ const right = request[j];
806
+ if (left !== void 0 && skippable(left)) {
807
+ offer(i + 1, j, fit);
808
+ }
809
+ if (right !== void 0 && skippable(right)) {
810
+ offer(i, j + 1, fit);
811
+ }
812
+ if (left === void 0 || right === void 0) {
813
+ continue;
814
+ }
815
+ if (left.kind === "star" && right.kind === "star") {
816
+ continue;
817
+ }
818
+ const kind = segmentFit(left, right);
819
+ if (kind === "apart" || kind === "guess") {
820
+ continue;
821
+ }
822
+ offer(
823
+ left.kind === "star" ? i : i + 1,
824
+ right.kind === "star" ? j : j + 1,
825
+ [
826
+ fit[0] + (kind === "stated" ? 1 : 0),
827
+ fit[1] + (kind === "shaped" ? shapedText(left) : 0)
828
+ ]
829
+ );
830
+ }
831
+ }
832
+ return best[route.length * width + request.length] ?? null;
833
+ }
834
+ function requestRank(route, request) {
835
+ let found = null;
836
+ for (const routeItems of patternOf(route)) {
837
+ for (const requestItems of patternOf(request)) {
838
+ const fit = bestFit(routeItems, requestItems);
839
+ const rank = fit === null ? null : [...fit, ...readingSpecificity(routeItems)];
840
+ if (rank !== null && (found === null || compareRanks(rank, found) > 0)) {
841
+ found = rank;
842
+ }
843
+ }
844
+ }
845
+ return found;
846
+ }
847
+ function isCatchAll(route) {
848
+ return patternOf(route).some(
849
+ (items) => items.length > 0 && items.every(absorbsAnything) && items.some((item) => item.kind === "star")
701
850
  );
702
- return [...loosest, 1 - alternatives.length];
703
851
  }
704
852
  function compareRanks(a, b) {
705
853
  const length = Math.max(a.length, b.length);
@@ -729,7 +877,18 @@ var RestSemanticsSchema = z7.object({
729
877
  * here so the pairing layer can see them without unwrapping any
730
878
  * metadata. Absent or empty for an inferred source.
731
879
  */
732
- declaredResponses: z7.array(z7.number()).optional()
880
+ declaredResponses: z7.array(z7.number()).optional(),
881
+ /**
882
+ * The host a client wrote in an absolute URL, with its port
883
+ * (`api.example.com:8443`). A piece suss could not read is written
884
+ * `{name}`. Absent for a relative URL and for a route.
885
+ */
886
+ host: z7.string().min(1).optional(),
887
+ /**
888
+ * True for a route the framework also serves with `.{format}` after
889
+ * its path, the way Rails serves `/search.json` from `get "search"`.
890
+ */
891
+ optionalFormat: z7.boolean().optional()
733
892
  });
734
893
  var OPENING_HOLE = /^\{([^{}]+)\}(.*)$/;
735
894
  function baseUrlReference(semantics) {
@@ -737,7 +896,10 @@ function baseUrlReference(semantics) {
737
896
  return label === void 0 ? null : referenceFromName(patternHole(label));
738
897
  }
739
898
  function normalizePath(path) {
740
- let normalized = path.replace(/:([a-zA-Z_]\w*)([?+*]?)/g, "{$1$2}");
899
+ let normalized = withoutParameterConstraints(path).replace(
900
+ /:([a-zA-Z_]\w*)([?+*]?)/g,
901
+ "{$1$2}"
902
+ );
741
903
  if (normalized.length > 1 && normalized.endsWith("/")) {
742
904
  normalized = normalized.slice(0, -1);
743
905
  }
@@ -747,6 +909,46 @@ function normalizePath(path) {
747
909
  );
748
910
  return normalized;
749
911
  }
912
+ function withoutParameterConstraints(path) {
913
+ let out = "";
914
+ let i = 0;
915
+ while (i < path.length) {
916
+ const name = /^:[a-zA-Z_]\w*\(/.exec(path.slice(i));
917
+ if (name === null) {
918
+ out += path[i];
919
+ i += 1;
920
+ continue;
921
+ }
922
+ const open = i + name[0].length - 1;
923
+ const close = closingParenthesis(path, open);
924
+ if (close === -1) {
925
+ return out + path.slice(i);
926
+ }
927
+ out += path.slice(i, open);
928
+ i = close + 1;
929
+ }
930
+ return out;
931
+ }
932
+ function closingParenthesis(text, open) {
933
+ let depth = 0;
934
+ for (let i = open; i < text.length; i += 1) {
935
+ const char = text[i];
936
+ if (char === "\\") {
937
+ i += 1;
938
+ continue;
939
+ }
940
+ if (char === "(") {
941
+ depth += 1;
942
+ }
943
+ if (char === ")") {
944
+ depth -= 1;
945
+ if (depth === 0) {
946
+ return i;
947
+ }
948
+ }
949
+ }
950
+ return -1;
951
+ }
750
952
  function pathShape(path) {
751
953
  return normalizePath(path).replace(/\{[^{}]*?([?+*]?)\}/g, "{$1}");
752
954
  }
@@ -762,6 +964,40 @@ function routePathAdmits(declaredPath, requestPath) {
762
964
  function routePathsMeet(a, b) {
763
965
  return pathsMeet(normalizePath(a), normalizePath(b));
764
966
  }
967
+ function readHost(semantics) {
968
+ const host = semantics.host;
969
+ return host === void 0 || /[{(]/.test(host) ? void 0 : host;
970
+ }
971
+ function hostsAgree(a, b) {
972
+ return readHost(a) === readHost(b);
973
+ }
974
+ function hostUnread(semantics) {
975
+ if (semantics.host !== void 0) {
976
+ return readHost(semantics) === void 0;
977
+ }
978
+ return baseUrlReference(semantics) !== null;
979
+ }
980
+ function servedPath(semantics, path) {
981
+ const normalized = normalizePath(path);
982
+ return semantics.optionalFormat === true ? `${normalized}(|.{format})` : normalized;
983
+ }
984
+ function withoutHost(semantics) {
985
+ const { host: _host, ...rest } = semantics;
986
+ return rest;
987
+ }
988
+ function groundedBaseUrl(semantics, deployment) {
989
+ const reference = baseUrlReference(semantics);
990
+ if (reference === null) {
991
+ return null;
992
+ }
993
+ const base = deployment.setTo(reference);
994
+ if (base === null) {
995
+ return null;
996
+ }
997
+ const rest = OPENING_HOLE.exec(semantics.path ?? "")?.[2] ?? "";
998
+ const grounded = pathAfterOrigin(`${base}${rest}`);
999
+ return { ...semantics, path: grounded === "" ? "/" : grounded };
1000
+ }
765
1001
  var restSemantics = defineBoundarySemantics({
766
1002
  name: "rest",
767
1003
  schema: RestSemanticsSchema,
@@ -800,19 +1036,46 @@ var restSemantics = defineBoundarySemantics({
800
1036
  if (semantics.method === null || semantics.path === null) {
801
1037
  return null;
802
1038
  }
803
- return `rest ${pathShape(semantics.path)}`;
1039
+ const shape = pathShape(pathAfterBaseUrl(normalizePath(semantics.path)));
1040
+ const host = readHost(semantics);
1041
+ return host === void 0 ? `rest ${shape}` : `rest //${host}${shape}`;
1042
+ },
1043
+ /**
1044
+ * A spec writes `/teams/{team_id}` where the route that serves it
1045
+ * writes `/teams/:id`. The method stays in, so a `"*"` handler is
1046
+ * never compared with the document for one method.
1047
+ */
1048
+ operationKey(semantics) {
1049
+ if (semantics.method === null || semantics.path === null) {
1050
+ return null;
1051
+ }
1052
+ return `${semantics.method.toUpperCase()} ${pathShape(semantics.path)}`;
804
1053
  },
805
1054
  sidesAgree(a, b) {
806
- return methodsAgree(a.method, b.method);
1055
+ return methodsAgree(a.method, b.method) && hostsAgree(a, b);
807
1056
  },
808
1057
  spansBuckets(semantics) {
809
- return semantics.path !== null && pathSpansShapes(normalizePath(semantics.path));
1058
+ return semantics.path !== null && (semantics.optionalFormat === true || pathSpansShapes(normalizePath(semantics.path)));
810
1059
  },
811
1060
  bucketsMeet(a, b) {
812
- return a.path !== null && b.path !== null && routePathsMeet(a.path, b.path);
1061
+ return a.path !== null && b.path !== null && hostsAgree(a, b) && pathsMeet(servedPath(a, a.path), servedPath(b, b.path));
813
1062
  },
814
- bucketRank(semantics) {
815
- return semantics.path === null ? [] : pathSpecificity(normalizePath(semantics.path));
1063
+ /**
1064
+ * A catch-all route never takes a call whose host suss could not
1065
+ * read, since nothing else ties that call to this app.
1066
+ */
1067
+ pairRank(provider, consumer) {
1068
+ if (provider.path === null || consumer.path === null) {
1069
+ return [];
1070
+ }
1071
+ const route = normalizePath(provider.path);
1072
+ if (hostUnread(consumer) && isCatchAll(route)) {
1073
+ return null;
1074
+ }
1075
+ return requestRank(
1076
+ servedPath(provider, provider.path),
1077
+ normalizePath(consumer.path)
1078
+ );
816
1079
  },
817
1080
  /**
818
1081
  * The identity key, with a missing half still readable: `ANY` when
@@ -853,27 +1116,29 @@ var restSemantics = defineBoundarySemantics({
853
1116
  },
854
1117
  /**
855
1118
  * A call whose base URL the deployment fills in, resolved to the
856
- * path it reaches.
1119
+ * path it reaches, and a call to a host that can be the project's
1120
+ * own, with the host left out so it meets the route.
857
1121
  *
858
- * The source cannot settle this on its own, so the adapter leaves
859
- * the hole in. `API_BASE` could be `http://backend.internal`, and
860
- * then the path is `/orders`. It could equally be `/api/v2`, and
1122
+ * The source cannot settle a base URL on its own, so the adapter
1123
+ * leaves the hole in. `API_BASE` could be `http://backend.internal`,
1124
+ * and then the path is `/orders`. It could equally be `/api/v2`, and
861
1125
  * then the path is `/api/v2/orders`. Filling in the deployed value
862
- * and reading the path back out gets the right one either way.
863
- * `baseUrlReference` finds the hole that is the base URL.
1126
+ * and reading the path back out gets the right one either way, and
1127
+ * a base the deployment sets counts as the app's own.
864
1128
  */
865
- groundName(semantics, deployment) {
866
- const reference = baseUrlReference(semantics);
867
- if (reference === null) {
868
- return null;
869
- }
870
- const base = deployment.setTo(reference);
871
- if (base === null) {
1129
+ /** A listed host matches with or without the port the call gives. */
1130
+ withoutOwnHost(semantics, ownHosts) {
1131
+ const host = semantics.host?.toLowerCase();
1132
+ if (host === void 0) {
872
1133
  return null;
873
1134
  }
874
- const rest = OPENING_HOLE.exec(semantics.path ?? "")?.[2] ?? "";
875
- const grounded = pathAfterOrigin(`${base}${rest}`);
876
- return { ...semantics, path: grounded === "" ? "/" : grounded };
1135
+ return ownHosts.has(host) || ownHosts.has(host.replace(/:\d+$/, "")) ? withoutHost(semantics) : null;
1136
+ },
1137
+ groundName(semantics, deployment) {
1138
+ const based = groundedBaseUrl(semantics, deployment);
1139
+ const grounded = based ?? semantics;
1140
+ const host = readHost(grounded);
1141
+ return host !== void 0 && hostCanBeOwn(host) ? withoutHost(grounded) : based;
877
1142
  },
878
1143
  nameReference: baseUrlReference,
879
1144
  ruleBoundary: {
@@ -1316,6 +1581,7 @@ export {
1316
1581
  referenceFromName,
1317
1582
  pathAfterOrigin,
1318
1583
  statesAnOrigin,
1584
+ hostCanBeOwn,
1319
1585
  rangedHole,
1320
1586
  setPiece,
1321
1587
  compareRanks,