All files / src parser.ts

95.65% Statements 44/46
88% Branches 22/25
100% Functions 4/4
95.65% Lines 44/46

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119  2x 2x       6x 6x 6x       6x 6x 6x 2x       6x       190x 190x   2x                           2x       166x   166x 190x 190x 190x   190x   190x 164x   190x     188x 24x 20x     184x   184x 184x 184x   36x     184x 184x   50x     184x 2x         182x 2x           180x                       156x   2x 4x       4x         156x    
import { ParseRoutesOptions, Protocol, Route, RouteParam } from './types'
import { validateProtocol } from './validation'
import { InvalidPatternError } from './errors'
 
function routeSpecificity(url: URL) {
  // Adapted from internal config service routing table implementation
  const hostParts = url.host.split('.')
  let hostScore = hostParts.length
  Iif (hostParts[0] === '*') {
    hostScore -= 2
  }
 
  const pathParts = url.pathname.split('/')
  let pathScore = pathParts.length
  if (pathParts[pathParts.length - 1] === '*') {
    pathScore -= 2
  }
 
  // The magic 26 comes directly from the cloudflare algorithm from workers-sdk
  return hostScore * 26 + pathScore
}
 
function parsePatternUrl(pattern: string): URL {
  try {
    return new URL(pattern)
  } catch {
    throw new InvalidPatternError(`Pattern ${pattern} is not a valid URL`, 'ERR_INVALID_URL')
  }
}
 
/**
 * Parses a list of route strings into an array of Route objects that contain detailed route information.
 *
 * @param {RouteParam[]} allRoutes - An array of route strings to be parsed. Each route string can contain protocols, hostnames, and paths.
 * @param {ParseRoutesOptions} options - Optional options.
 * @return {Route[]} An array of parsed Route objects with details such as hostname, path, and protocol.
 *
 * @throws {InvalidProtocolError} If provided URL protocol in one of the routes is not `http:` or `https:`.
 * @throws {InvalidPatternError} If a route contains a query string or infix wildcard which is not allowed.
 */
export function parseRoutes<Metadata>(
  allRoutes: RouteParam<Metadata>[],
  { sortBySpecificity = false }: ParseRoutesOptions = {}
): Route<Metadata>[] {
  const routes: Route<Metadata>[] = []
 
  for (const rawRoute of allRoutes) {
    const route = typeof rawRoute === 'string' ? rawRoute : rawRoute.url
    const metadata = typeof rawRoute === 'string' ? undefined : rawRoute.metadata
    const hasProtocol = /^[a-z0-9+\-.]+:\/\//i.test(route)
 
    let urlInput = route
    // If route is missing a protocol, give it one so it parses
    if (!hasProtocol) {
      urlInput = `https://${urlInput}`
    }
    const url = parsePatternUrl(urlInput)
 
    let protocol: Protocol | undefined
    if (hasProtocol) {
      validateProtocol(url.protocol)
      protocol = url.protocol
    }
 
    const specificity = sortBySpecificity ? routeSpecificity(url) : undefined
 
    const allowHostnamePrefix = url.hostname.startsWith('*')
    const anyHostname = url.hostname === '*'
    if (allowHostnamePrefix && !anyHostname) {
      // Remove leading "*"
      url.hostname = url.hostname.substring(1)
    }
 
    const allowPathSuffix = url.pathname.endsWith('*')
    if (allowPathSuffix) {
      // Remove trailing "*"
      url.pathname = url.pathname.substring(0, url.pathname.length - 1)
    }
 
    if (url.search) {
      throw new InvalidPatternError(
        `Route "${route}" contains a query string. This is not allowed.`,
        'ERR_QUERY_STRING'
      )
    }
    if (url.toString().includes('*') && !anyHostname) {
      throw new InvalidPatternError(
        `Route "${route}" contains an infix wildcard. This is not allowed.`,
        'ERR_INFIX_WILDCARD'
      )
    }
 
    routes.push({
      route,
      metadata,
      specificity,
      protocol,
      wildcardHostnamePrefix: allowHostnamePrefix,
      hostname: anyHostname ? '' : url.hostname,
      path: url.pathname,
      wildcardPathSuffix: allowPathSuffix,
    })
  }
 
  if (sortBySpecificity) {
    // Sort with the highest specificity first
    routes.sort((a, b) => {
      Iif (a.specificity === b.specificity) {
        // If routes are equally specific, sort by the longest route first
        return b.route.length - a.route.length
      } else {
        return b.specificity! - a.specificity!
      }
    })
  }
 
  return routes
}