1import fs from 'fs/promises';
2import { debounce } from 'lodash';
3import { Server } from 'metro';
4import path from 'path';
5
6import { directoryExistsAsync } from '../../../utils/dir';
7import { unsafeTemplate } from '../../../utils/template';
8import { ServerLike } from '../BundlerDevServer';
9import { metroWatchTypeScriptFiles } from '../metro/metroWatchTypeScriptFiles';
10
11// /test/[...param1]/[param2]/[param3] - captures ["param1", "param2", "param3"]
12export const CAPTURE_DYNAMIC_PARAMS = /\[(?:\.{3})?(\w*?)[\]$]/g;
13// /[...param1]/ - Match [...param1]
14export const CATCH_ALL = /\[\.\.\..+?\]/g;
15// /[param1] - Match [param1]
16export const SLUG = /\[.+?\]/g;
17// /(group1,group2,group3)/test - match (group1,group2,group3)
18export const ARRAY_GROUP_REGEX = /\(\s*\w[\w\s]*?,.*?\)/g;
19// /(group1,group2,group3)/test - captures ["group1", "group2", "group3"]
20export const CAPTURE_GROUP_REGEX = /[\\(,]\s*(\w[\w\s]*?)\s*(?=[,\\)])/g;
21
22export interface SetupTypedRoutesOptions {
23  server?: ServerLike;
24  metro?: Server | null;
25  typesDirectory: string;
26  projectRoot: string;
27  /** Absolute expo router routes directory. */
28  routerDirectory: string;
29}
30
31export async function setupTypedRoutes({
32  server,
33  metro,
34  typesDirectory,
35  projectRoot,
36  routerDirectory,
37}: SetupTypedRoutesOptions) {
38  const { filePathToRoute, staticRoutes, dynamicRoutes, addFilePath, isRouteFile } =
39    getTypedRoutesUtils(routerDirectory);
40
41  if (metro && server) {
42    // Setup out watcher first
43    metroWatchTypeScriptFiles({
44      projectRoot,
45      server,
46      metro,
47      eventTypes: ['add', 'delete', 'change'],
48      async callback({ filePath, type }) {
49        if (!isRouteFile(filePath)) {
50          return;
51        }
52
53        let shouldRegenerate = false;
54
55        if (type === 'delete') {
56          const route = filePathToRoute(filePath);
57          staticRoutes.delete(route);
58          dynamicRoutes.delete(route);
59          shouldRegenerate = true;
60        } else {
61          shouldRegenerate = addFilePath(filePath);
62        }
63
64        if (shouldRegenerate) {
65          regenerateRouterDotTS(
66            typesDirectory,
67            new Set([...staticRoutes.values()].flatMap((v) => Array.from(v))),
68            new Set([...dynamicRoutes.values()].flatMap((v) => Array.from(v))),
69            new Set(dynamicRoutes.keys())
70          );
71        }
72      },
73    });
74  }
75
76  if (await directoryExistsAsync(routerDirectory)) {
77    // Do we need to walk the entire tree on startup?
78    // Idea: Store the list of files in the last write, then simply check Git for what files have changed
79    await walk(routerDirectory, addFilePath);
80  }
81
82  regenerateRouterDotTS(
83    typesDirectory,
84    new Set([...staticRoutes.values()].flatMap((v) => Array.from(v))),
85    new Set([...dynamicRoutes.values()].flatMap((v) => Array.from(v))),
86    new Set(dynamicRoutes.keys())
87  );
88}
89
90/**
91 * Generate a router.d.ts file that contains all of the routes in the project.
92 * Should be debounced as its very common for developers to make changes to multiple files at once (eg Save All)
93 */
94const regenerateRouterDotTS = debounce(
95  async (
96    typesDir: string,
97    staticRoutes: Set<string>,
98    dynamicRoutes: Set<string>,
99    dynamicRouteTemplates: Set<string>
100  ) => {
101    await fs.mkdir(typesDir, { recursive: true });
102    await fs.writeFile(
103      path.resolve(typesDir, './router.d.ts'),
104      getTemplateString(staticRoutes, dynamicRoutes, dynamicRouteTemplates)
105    );
106  },
107  100
108);
109
110/*
111 * This is exported for testing purposes
112 */
113export function getTemplateString(
114  staticRoutes: Set<string>,
115  dynamicRoutes: Set<string>,
116  dynamicRouteTemplates: Set<string>
117) {
118  return routerDotTSTemplate({
119    staticRoutes: setToUnionType(staticRoutes),
120    dynamicRoutes: setToUnionType(dynamicRoutes),
121    dynamicRouteParams: setToUnionType(dynamicRouteTemplates),
122  });
123}
124
125/**
126 * Utility functions for typed routes
127 *
128 * These are extracted for easier testing
129 */
130export function getTypedRoutesUtils(appRoot: string, filePathSeperator = path.sep) {
131  /*
132   * staticRoutes are a map where the key if the route without groups and the value
133   *   is another set of all group versions of the route. e.g,
134   *    Map([
135   *      ["/", ["/(app)/(notes)", "/(app)/(profile)"]
136   *    ])
137   */
138  const staticRoutes = new Map<string, Set<string>>([['/', new Set('/')]]);
139  /*
140   * dynamicRoutes are the same as staticRoutes (key if the resolved route,
141   *   and the value is a set of possible routes). e.g:
142   *
143   * /[...fruits] -> /${CatchAllRoutePart<T>}
144   * /color/[color] -> /color/${SingleRoutePart<T>}
145   *
146   * The keys of this map are also important, as they can be used as "static" types
147   * <Link href={{ pathname: "/[...fruits]",params: { fruits: ["apple"] } }} />
148   */
149  const dynamicRoutes = new Map<string, Set<string>>();
150
151  function normalizedFilePath(filePath: string) {
152    return filePath.replaceAll(filePathSeperator, '/');
153  }
154
155  const normalizedAppRoot = normalizedFilePath(appRoot);
156
157  const filePathToRoute = (filePath: string) => {
158    return normalizedFilePath(filePath)
159      .replace(normalizedAppRoot, '')
160      .replace(/index\.[jt]sx?/, '')
161      .replace(/\.[jt]sx?$/, '');
162  };
163
164  const isRouteFile = (filePath: string) => {
165    // Layout and filenames starting with `+` are not routes
166    if (filePath.match(/_layout\.[tj]sx?$/) || filePath.match(/\/\+/)) {
167      return false;
168    }
169
170    // Route files must be nested with in the appRoot
171    const relative = path.relative(appRoot, filePath);
172    return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
173  };
174
175  const addFilePath = (filePath: string): boolean => {
176    if (!isRouteFile(filePath)) {
177      return false;
178    }
179
180    const route = filePathToRoute(filePath);
181
182    // We have already processed this file
183    if (staticRoutes.has(route) || dynamicRoutes.has(route)) {
184      return false;
185    }
186
187    const dynamicParams = new Set(
188      [...route.matchAll(CAPTURE_DYNAMIC_PARAMS)].map((match) => match[1])
189    );
190    const isDynamic = dynamicParams.size > 0;
191
192    const addRoute = (originalRoute: string, route: string) => {
193      if (isDynamic) {
194        let set = dynamicRoutes.get(originalRoute);
195
196        if (!set) {
197          set = new Set();
198          dynamicRoutes.set(originalRoute, set);
199        }
200
201        set.add(
202          route
203            .replaceAll(CATCH_ALL, '${CatchAllRoutePart<T>}')
204            .replaceAll(SLUG, '${SingleRoutePart<T>}')
205        );
206      } else {
207        let set = staticRoutes.get(originalRoute);
208
209        if (!set) {
210          set = new Set();
211          staticRoutes.set(originalRoute, set);
212        }
213
214        set.add(route);
215      }
216    };
217
218    if (!route.match(ARRAY_GROUP_REGEX)) {
219      addRoute(route, route);
220    }
221
222    // Does this route have a group? eg /(group)
223    if (route.includes('/(')) {
224      const routeWithoutGroups = route.replace(/\/\(.+?\)/g, '');
225      addRoute(route, routeWithoutGroups);
226
227      // If there are multiple groups, we need to expand them
228      // eg /(test1,test2)/page => /test1/page & /test2/page
229      for (const routeWithSingleGroup of extrapolateGroupRoutes(route)) {
230        addRoute(route, routeWithSingleGroup);
231      }
232    }
233
234    return true;
235  };
236
237  return {
238    staticRoutes,
239    dynamicRoutes,
240    filePathToRoute,
241    addFilePath,
242    isRouteFile,
243  };
244}
245
246export const setToUnionType = <T>(set: Set<T>) => {
247  return set.size > 0 ? [...set].map((s) => `\`${s}\``).join(' | ') : 'never';
248};
249
250/**
251 * Recursively walk a directory and call the callback with the file path.
252 */
253async function walk(directory: string, callback: (filePath: string) => void) {
254  const files = await fs.readdir(directory);
255  for (const file of files) {
256    const p = path.join(directory, file);
257    if ((await fs.stat(p)).isDirectory()) {
258      await walk(p, callback);
259    } else {
260      // Normalise the paths so they are easier to convert to URLs
261      const normalizedPath = p.replaceAll(path.sep, '/');
262      callback(normalizedPath);
263    }
264  }
265}
266
267/**
268 * Given a route, return all possible routes that could be generated from it.
269 */
270export function extrapolateGroupRoutes(
271  route: string,
272  routes: Set<string> = new Set()
273): Set<string> {
274  // Create a version with no groups. We will then need to cleanup double and/or trailing slashes
275  routes.add(route.replaceAll(ARRAY_GROUP_REGEX, '').replaceAll(/\/+/g, '/').replace(/\/$/, ''));
276
277  const match = route.match(ARRAY_GROUP_REGEX);
278
279  if (!match) {
280    routes.add(route);
281    return routes;
282  }
283
284  const groupsMatch = match[0];
285
286  for (const group of groupsMatch.matchAll(CAPTURE_GROUP_REGEX)) {
287    extrapolateGroupRoutes(route.replace(groupsMatch, `(${group[1].trim()})`), routes);
288  }
289
290  return routes;
291}
292
293/**
294 * NOTE: This code refers to a specific version of `expo-router` and is therefore unsafe to
295 * mix with arbitrary versions.
296 * TODO: Version this code with `expo-router` or version expo-router with `@expo/cli`.
297 */
298const routerDotTSTemplate = unsafeTemplate`/* eslint-disable @typescript-eslint/no-unused-vars */
299/* eslint-disable import/export */
300/* eslint-disable @typescript-eslint/ban-types */
301declare module "expo-router" {
302  import type { LinkProps as OriginalLinkProps } from 'expo-router/build/link/Link';
303  import type { Router as OriginalRouter } from 'expo-router/src/types';
304  export * from 'expo-router/build';
305
306  // prettier-ignore
307  type StaticRoutes = ${'staticRoutes'};
308  // prettier-ignore
309  type DynamicRoutes<T extends string> = ${'dynamicRoutes'};
310  // prettier-ignore
311  type DynamicRouteTemplate = ${'dynamicRouteParams'};
312
313  type RelativePathString = \`./\${string}\` | \`../\${string}\` | '..';
314  type AbsoluteRoute = DynamicRouteTemplate | StaticRoutes;
315  type ExternalPathString = \`http\${string}\`;
316  type ExpoRouterRoutes = DynamicRouteTemplate | StaticRoutes | RelativePathString;
317  type AllRoutes = ExpoRouterRoutes | ExternalPathString;
318
319  /****************
320   * Route Utils  *
321   ****************/
322
323  type SearchOrHash = \`?\${string}\` | \`#\${string}\`;
324  type UnknownInputParams = Record<string, string | number | (string | number)[]>;
325  type UnknownOutputParams = Record<string, string | string[]>;
326
327  /**
328   * Return only the RoutePart of a string. If the string has multiple parts return never
329   *
330   * string   | type
331   * ---------|------
332   * 123      | 123
333   * /123/abc | never
334   * 123?abc  | never
335   * ./123    | never
336   * /123     | never
337   * 123/../  | never
338   */
339  type SingleRoutePart<S extends string> = S extends \`\${string}/\${string}\`
340    ? never
341    : S extends \`\${string}\${SearchOrHash}\`
342    ? never
343    : S extends ''
344    ? never
345    : S extends \`(\${string})\`
346    ? never
347    : S extends \`[\${string}]\`
348    ? never
349    : S;
350
351  /**
352   * Return only the CatchAll router part. If the string has search parameters or a hash return never
353   */
354  type CatchAllRoutePart<S extends string> = S extends \`\${string}\${SearchOrHash}\`
355    ? never
356    : S extends ''
357    ? never
358    : S extends \`\${string}(\${string})\${string}\`
359    ? never
360    : S extends \`\${string}[\${string}]\${string}\`
361    ? never
362    : S;
363
364  // type OptionalCatchAllRoutePart<S extends string> = S extends \`\${string}\${SearchOrHash}\` ? never : S
365
366  /**
367   * Return the name of a route parameter
368   * '[test]'    -> 'test'
369   * 'test'      -> never
370   * '[...test]' -> '...test'
371   */
372  type IsParameter<Part> = Part extends \`[\${infer ParamName}]\` ? ParamName : never;
373
374  /**
375   * Return a union of all parameter names. If there are no names return never
376   *
377   * /[test]         -> 'test'
378   * /[abc]/[...def] -> 'abc'|'...def'
379   */
380  type ParameterNames<Path> = Path extends \`\${infer PartA}/\${infer PartB}\`
381    ? IsParameter<PartA> | ParameterNames<PartB>
382    : IsParameter<Path>;
383
384  /**
385   * Returns all segements of a route.
386   *
387   * /(group)/123/abc/[id]/[...rest] -> ['(group)', '123', 'abc', '[id]', '[...rest]'
388   */
389  type RouteSegments<Path> = Path extends \`\${infer PartA}/\${infer PartB}\`
390    ? PartA extends '' | '.'
391      ? [...RouteSegments<PartB>]
392      : [PartA, ...RouteSegments<PartB>]
393    : Path extends ''
394    ? []
395    : [Path];
396
397  /**
398   * Returns a Record of the routes parameters as strings and CatchAll parameters
399   *
400   * There are two versions, input and output, as you can input 'string | number' but
401   *  the output will always be 'string'
402   *
403   * /[id]/[...rest] -> { id: string, rest: string[] }
404   * /no-params      -> {}
405   */
406  type InputRouteParams<Path> = {
407    [Key in ParameterNames<Path> as Key extends \`...\${infer Name}\`
408      ? Name
409      : Key]: Key extends \`...\${string}\` ? (string | number)[] : string | number;
410  } & UnknownInputParams;
411
412  type OutputRouteParams<Path> = {
413    [Key in ParameterNames<Path> as Key extends \`...\${infer Name}\`
414      ? Name
415      : Key]: Key extends \`...\${string}\` ? string[] : string;
416  } & UnknownOutputParams;
417
418  /**
419   * Returns the search parameters for a route.
420   */
421  export type SearchParams<T extends AllRoutes> = T extends DynamicRouteTemplate
422    ? OutputRouteParams<T>
423    : T extends StaticRoutes
424    ? never
425    : UnknownOutputParams;
426
427  /**
428   * Route is mostly used as part of Href to ensure that a valid route is provided
429   *
430   * Given a dynamic route, this will return never. This is helpful for conditional logic
431   *
432   * /test         -> /test, /test2, etc
433   * /test/[abc]   -> never
434   * /test/resolve -> /test, /test2, etc
435   *
436   * Note that if we provide a value for [abc] then the route is allowed
437   *
438   * This is named Route to prevent confusion, as users they will often see it in tooltips
439   */
440  export type Route<T> = T extends string
441    ? T extends DynamicRouteTemplate
442      ? never
443      :
444          | StaticRoutes
445          | RelativePathString
446          | ExternalPathString
447          | (T extends \`\${infer P}\${SearchOrHash}\`
448              ? P extends DynamicRoutes<infer _>
449                ? T
450                : never
451              : T extends DynamicRoutes<infer _>
452              ? T
453              : never)
454    : never;
455
456  /*********
457   * Href  *
458   *********/
459
460  export type Href<T> = T extends Record<'pathname', string> ? HrefObject<T> : Route<T>;
461
462  export type HrefObject<
463    R extends Record<'pathname', string>,
464    P = R['pathname'],
465  > = P extends DynamicRouteTemplate
466    ? { pathname: P; params: InputRouteParams<P> }
467    : P extends Route<P>
468    ? { pathname: Route<P> | DynamicRouteTemplate; params?: never | InputRouteParams<never> }
469    : never;
470
471  /***********************
472   * Expo Router Exports *
473   ***********************/
474
475  export type Router = Omit<OriginalRouter, 'push' | 'replace' | 'setParams'> & {
476    /** Navigate to the provided href. */
477    push: <T>(href: Href<T>) => void;
478    /** Navigate to route without appending to the history. */
479    replace: <T>(href: Href<T>) => void;
480    /** Update the current route query params. */
481    setParams: <T = ''>(params?: T extends '' ? Record<string, string> : InputRouteParams<T>) => void;
482  };
483
484  /** The imperative router. */
485  export const router: Router;
486
487  /************
488   * <Link /> *
489   ************/
490  export interface LinkProps<T> extends OriginalLinkProps {
491    href: Href<T>;
492  }
493
494  export interface LinkComponent {
495    <T>(props: React.PropsWithChildren<LinkProps<T>>): JSX.Element;
496    /** Helper method to resolve an Href object into a string. */
497    resolveHref: <T>(href: Href<T>) => string;
498  }
499
500  /**
501   * Component to render link to another route using a path.
502   * Uses an anchor tag on the web.
503   *
504   * @param props.href Absolute path to route (e.g. \`/feeds/hot\`).
505   * @param props.replace Should replace the current route without adding to the history.
506   * @param props.asChild Forward props to child component. Useful for custom buttons.
507   * @param props.children Child elements to render the content.
508   */
509  export const Link: LinkComponent;
510
511  /** Redirects to the href as soon as the component is mounted. */
512  export const Redirect: <T>(
513    props: React.PropsWithChildren<{ href: Href<T> }>
514  ) => JSX.Element;
515
516  /************
517   * Hooks *
518   ************/
519  export function useRouter(): Router;
520
521  export function useLocalSearchParams<
522    T extends AllRoutes | UnknownOutputParams = UnknownOutputParams,
523  >(): T extends AllRoutes ? SearchParams<T> : T;
524
525  /** @deprecated renamed to \`useGlobalSearchParams\` */
526  export function useSearchParams<
527    T extends AllRoutes | UnknownOutputParams = UnknownOutputParams,
528  >(): T extends AllRoutes ? SearchParams<T> : T;
529
530  export function useGlobalSearchParams<
531    T extends AllRoutes | UnknownOutputParams = UnknownOutputParams,
532  >(): T extends AllRoutes ? SearchParams<T> : T;
533
534  export function useSegments<
535    T extends AbsoluteRoute | RouteSegments<AbsoluteRoute> | RelativePathString,
536  >(): T extends AbsoluteRoute ? RouteSegments<T> : T extends string ? string[] : T;
537}
538`;
539