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