urls.ts
  1  /**
  2   * Created by keithpk on 12/2/16.
  3   */
  4  
  5  import { isNothing, Nothing, Opt } from "@jet/environment/types/optional";
  6  import { isDefinedNonNullNonEmpty, isNullOrEmpty } from "./server-data";
  7  
  8  export type Query = {
  9      [key: string]: string | undefined;
 10  };
 11  
 12  export type URLComponent = "protocol" | "username" | "password" | "host" | "port" | "pathname" | "query" | "hash";
 13  
 14  const protocolRegex = /^([a-z][a-z0-9.+-]*:)(\/\/)?([\S\s]*)/i;
 15  const queryParamRegex = /([^=?&]+)=?([^&]*)/g;
 16  const componentOrder: URLComponent[] = ["hash", "query", "pathname", "host"];
 17  
 18  type URLSplitStyle = "prefix" | "suffix";
 19  
 20  type URLSplitResult = {
 21      result?: string;
 22      remainder: string;
 23  };
 24  
 25  function splitUrlComponent(input: string, marker: string, style: URLSplitStyle): URLSplitResult {
 26      const index = input.indexOf(marker);
 27      let result;
 28      let remainder = input;
 29      if (index !== -1) {
 30          const prefix = input.slice(0, index);
 31          const suffix = input.slice(index + marker.length, input.length);
 32  
 33          if (style === "prefix") {
 34              result = prefix;
 35              remainder = suffix;
 36          } else {
 37              result = suffix;
 38              remainder = prefix;
 39          }
 40      }
 41  
 42      // log("Token: " + marker + " String: " + input, " Result: " + result + " Remainder: " + remainder)
 43  
 44      return {
 45          result: result,
 46          remainder: remainder,
 47      };
 48  }
 49  
 50  export class URL {
 51      protocol?: Opt<string>;
 52      username: string;
 53      password: string;
 54      host?: Opt<string>;
 55      port: string;
 56      pathname?: Opt<string>;
 57      query?: Query = {};
 58      hash?: string;
 59  
 60      constructor(url?: string) {
 61          if (isNullOrEmpty(url)) {
 62              return;
 63          }
 64  
 65          // Split the protocol from the rest of the urls
 66          let remainder = url;
 67          const match = protocolRegex.exec(url);
 68          if (match != null) {
 69              // Pull out the protocol
 70              let protocol = match[1];
 71              if (protocol) {
 72                  protocol = protocol.split(":")[0];
 73              }
 74  
 75              this.protocol = protocol;
 76  
 77              // Save the remainder
 78              remainder = match[3];
 79          }
 80  
 81          // Then match each component in a specific order
 82          let parse: URLSplitResult = { remainder: remainder, result: undefined };
 83          for (const component of componentOrder) {
 84              if (!parse.remainder) {
 85                  break;
 86              }
 87  
 88              switch (component) {
 89                  case "hash": {
 90                      parse = splitUrlComponent(parse.remainder, "#", "suffix");
 91                      this.hash = parse.result;
 92                      break;
 93                  }
 94                  case "query": {
 95                      parse = splitUrlComponent(parse.remainder, "?", "suffix");
 96                      if (isDefinedNonNullNonEmpty(parse.result)) {
 97                          this.query = URL.queryFromString(parse.result);
 98                      }
 99                      break;
100                  }
101                  case "pathname": {
102                      parse = splitUrlComponent(parse.remainder, "/", "suffix");
103  
104                      if (isDefinedNonNullNonEmpty(parse.result)) {
105                          // Replace the initial /, since paths require it
106                          this.pathname = "/" + parse.result;
107                      }
108                      break;
109                  }
110                  case "host": {
111                      if (parse.remainder) {
112                          const authorityParse = splitUrlComponent(parse.remainder, "@", "prefix");
113                          const userInfo = authorityParse.result;
114                          const hostPort = authorityParse.remainder;
115                          if (isDefinedNonNullNonEmpty(userInfo)) {
116                              const userInfoSplit = userInfo.split(":");
117                              this.username = decodeURIComponent(userInfoSplit[0]);
118                              this.password = decodeURIComponent(userInfoSplit[1]);
119                          }
120  
121                          if (hostPort) {
122                              const hostPortSplit = hostPort.split(":");
123                              this.host = hostPortSplit[0];
124                              this.port = hostPortSplit[1];
125                          }
126                      }
127                      break;
128                  }
129                  default: {
130                      throw new Error("Unhandled case!");
131                  }
132              }
133          }
134      }
135  
136      set(component: URLComponent, value: string | Query): URL {
137          if (isNullOrEmpty(value)) {
138              return this;
139          }
140  
141          if (component === "query") {
142              if (typeof value === "string") {
143                  value = URL.queryFromString(value);
144              }
145          }
146  
147          switch (component) {
148              // Exhaustive match to make sure TS property minifiers and other
149              // transformer plugins do not break this code.
150              case "protocol":
151                  this.protocol = value as string;
152                  break;
153              case "username":
154                  this.username = value as string;
155                  break;
156              case "password":
157                  this.password = value as string;
158                  break;
159              case "port":
160                  this.port = value as string;
161                  break;
162              case "pathname":
163                  this.pathname = value as string;
164                  break;
165              case "query":
166                  this.query = value as Query;
167                  break;
168              case "hash":
169                  this.hash = value as string;
170                  break;
171              default:
172                  // The fallback for component which is not a property of URL object.
173                  this[component] = value as string;
174                  break;
175          }
176          return this;
177      }
178  
179      private get(component: URLComponent): string | Query | Nothing {
180          switch (component) {
181              // Exhaustive match to make sure TS property minifiers and other
182              // transformer plugins do not break this code.
183              case "protocol":
184                  return this.protocol;
185              case "username":
186                  return this.username;
187              case "password":
188                  return this.password;
189              case "port":
190                  return this.port;
191              case "pathname":
192                  return this.pathname;
193              case "query":
194                  return this.query;
195              case "hash":
196                  return this.hash;
197              default:
198                  // The fallback for component which is not a property of URL object.
199                  return this[component];
200          }
201      }
202  
203      append(component: URLComponent, value: string | Query): URL {
204          const existingValue = this.get(component);
205          let newValue;
206  
207          if (component === "query") {
208              if (typeof value === "string") {
209                  value = URL.queryFromString(value);
210              }
211  
212              if (typeof existingValue === "string") {
213                  newValue = { existingValue, ...value };
214              } else {
215                  newValue = { ...existingValue, ...value };
216              }
217          } else {
218              let existingValueString = existingValue as string;
219  
220              if (!existingValueString) {
221                  existingValueString = "";
222              }
223  
224              let newValueString = existingValueString;
225  
226              if (component === "pathname") {
227                  const pathLength = existingValueString.length;
228                  if (!pathLength || existingValueString[pathLength - 1] !== "/") {
229                      newValueString += "/";
230                  }
231              }
232  
233              // eslint-disable-next-line @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-base-to-string
234              newValueString += value;
235              newValue = newValueString;
236          }
237  
238          return this.set(component, newValue);
239      }
240  
241      param(key: string, value?: string): URL {
242          if (!key) {
243              return this;
244          }
245          if (this.query == null) {
246              this.query = {};
247          }
248          this.query[key] = value;
249          return this;
250      }
251  
252      removeParam(key: string): URL {
253          if (!key || this.query == null) {
254              return this;
255          }
256          if (this.query[key] !== undefined) {
257              delete this.query[key];
258          }
259          return this;
260      }
261  
262      /**
263       * Push a new string value onto the path for this url
264       * @returns URL this object with the updated path.
265       */
266      path(value: string): URL {
267          return this.append("pathname", value);
268      }
269  
270      pathExtension(): Opt<string> {
271          // Extract path extension if one exists
272          if (isNothing(this.pathname)) {
273              return null;
274          }
275  
276          const lastFilenameComponents = this.pathname
277              .split("/")
278              .filter((item) => item.length > 0) // Remove any double or trailing slashes
279              .pop()
280              ?.split(".");
281          if (lastFilenameComponents === undefined) {
282              return null;
283          }
284          if (
285              lastFilenameComponents.filter((part) => {
286                  return part !== "";
287              }).length < 2 // Remove any empty parts (e.g. .ssh_config -> ["ssh_config"])
288          ) {
289              return null;
290          }
291  
292          return lastFilenameComponents.pop();
293      }
294  
295      /**
296       * Returns the path components of the URL
297       * @returns An array of non-empty path components from `urls`.
298       */
299      pathComponents(): string[] {
300          if (isNullOrEmpty(this.pathname)) {
301              return [];
302          }
303  
304          return this.pathname.split("/").filter((component) => component.length > 0);
305      }
306  
307      /**
308       * Returns the last path component from this url, updating the url to not include this path component
309       * @returns String the last path component from this url.
310       */
311      popPathComponent(): string | null {
312          if (isNullOrEmpty(this.pathname)) {
313              return null;
314          }
315  
316          const lastPathComponent = this.pathname.slice(this.pathname.lastIndexOf("/") + 1);
317  
318          if (lastPathComponent.length === 0) {
319              return null;
320          }
321  
322          this.pathname = this.pathname.slice(0, this.pathname.lastIndexOf("/"));
323  
324          return lastPathComponent;
325      }
326  
327      /**
328       * Same as toString
329       *
330       * @returns {string} A string representation of the URL
331       */
332      build(): string {
333          return this.toString();
334      }
335  
336      /**
337       * Converts the URL to a string
338       *
339       * @returns {string} A string representation of the URL
340       */
341      toString(): string {
342          let url = "";
343  
344          if (isDefinedNonNullNonEmpty(this.protocol)) {
345              url += this.protocol + "://";
346          }
347  
348          if (this.username) {
349              url += encodeURIComponent(this.username);
350  
351              if (this.password) {
352                  url += ":" + encodeURIComponent(this.password);
353              }
354  
355              url += "@";
356          }
357  
358          if (isDefinedNonNullNonEmpty(this.host)) {
359              url += this.host;
360  
361              if (this.port) {
362                  url += ":" + this.port;
363              }
364          }
365  
366          if (isDefinedNonNullNonEmpty(this.pathname)) {
367              url += this.pathname;
368              /// Trim off trailing path separators when we have a valid path
369              /// We don't do this unless pathname has elements otherwise we will trim the `://`
370              if (url.endsWith("/") && this.pathname.length > 0) {
371                  url = url.slice(0, -1);
372              }
373          }
374  
375          if (this.query != null && Object.keys(this.query).length > 0) {
376              url += "?" + URL.toQueryString(this.query);
377          }
378  
379          if (isDefinedNonNullNonEmpty(this.hash)) {
380              url += "#" + this.hash;
381          }
382  
383          return url;
384      }
385  
386      // ----------------
387      // Static API
388      // ----------------
389  
390      /**
391       * Converts a string into a query dictionary
392       * @param query The string to parse
393       * @returns The query dictionary containing the key-value pairs in the query string
394       */
395      static queryFromString(query: string): Query {
396          const result = {};
397  
398          let parseResult = queryParamRegex.exec(query);
399          while (parseResult != null) {
400              const key = decodeURIComponent(parseResult[1]);
401              const value = decodeURIComponent(parseResult[2]);
402              result[key] = value;
403              parseResult = queryParamRegex.exec(query);
404          }
405  
406          return result;
407      }
408  
409      /**
410       * Converts a query dictionary into a query string
411       *
412       * @param query The query dictionary
413       * @returns {string} The string representation of the query dictionary
414       */
415      static toQueryString(query: Query) {
416          let queryString = "";
417  
418          let first = true;
419          for (const key of Object.keys(query)) {
420              if (!first) {
421                  queryString += "&";
422              }
423              first = false;
424  
425              queryString += encodeURIComponent(key);
426  
427              const value = query[key];
428              if (isDefinedNonNullNonEmpty(value) && value.length) {
429                  queryString += "=" + encodeURIComponent(value);
430              }
431          }
432  
433          return queryString;
434      }
435  
436      /**
437       * Convenience method to instantiate a URL from a string
438       * @param url The URL string to parse
439       * @returns {URL} The new URL object representing the URL
440       */
441      static from(url: string): URL {
442          return new URL(url);
443      }
444  
445      /**
446       * Convenience method to instantiate a URL from numerous (optional) components
447       * @param protocol The protocol type
448       * @param host The host name
449       * @param path The path
450       * @param query The query
451       * @param hash The hash
452       * @returns {URL} The new URL object representing the URL
453       */
454      static fromComponents(
455          protocol?: Opt<string>,
456          host?: Opt<string>,
457          path?: Opt<string>,
458          query?: Query,
459          hash?: string,
460      ): URL {
461          const url = new URL();
462          url.protocol = protocol;
463          url.host = host;
464          url.pathname = path;
465          url.query = query;
466          url.hash = hash;
467          return url;
468      }
469  }