tier-synthesizer.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  1. /**
  2. * Cross-tier channels — the web's equivalent of the React Native bridge.
  3. *
  4. * A web app is two programs that talk over a wire the graph cannot see: the
  5. * page calls `fetch('/api/users', { method: 'POST' })` and the API's
  6. * `app.post('/api/users', createUser)` answers; a service puts `'welcome'` on
  7. * the `email` queue and a `@Process('welcome')` method picks it up; a
  8. * gateway's `this.server.emit('message')` lands in the component that wrote
  9. * `socket.on('message', …)`. Each hop is a string on both sides, which is the
  10. * evidence that lets a synthesizer close it — exactly as the RN event channel
  11. * pairs `sendEvent(withName: "x")` with `addListener('x')`.
  12. *
  13. * Three channels, one scan:
  14. *
  15. * 1. **`http-client`** — a literal path in a client call (`fetch`, `axios.post`,
  16. * `ky`, `got`, `$fetch`, `useFetch`, `useSWR`, or a project instance made by
  17. * `axios.create(…)` / `ky.extend(…)`) → the ONE route node `METHOD path` it
  18. * denotes. Template holes match a `:param`; a hole in front of the path
  19. * (`${API_URL}/users`) matches a route by its tail; a variable url, a path
  20. * no route serves, or a path two routes serve alike produce nothing.
  21. * Edge: enclosing function → route, `tier: 'client→server'`.
  22. * 2. **`queue-job`** — `queue.add('job', …)` where the queue is named (`new
  23. * Queue('email')`, `@InjectQueue('email')`) → the `@Process('job')` method
  24. * of the `@Processor('email')` class, a WorkerHost's `process`, a
  25. * `new Worker('email', handler)`, or Bull's `queue.process('job', handler)`.
  26. * 3. **`event-bus`** — `eventEmitter.emit('user.created')` → `@OnEvent('user.created')`
  27. * (globs honoured); and sockets in both directions: a client's
  28. * `socket.emit('x')` → the server's `@SubscribeMessage('x')` / `socket.on('x')`
  29. * (`tier: 'client→server'`), the server's `server.emit('x')` → the client's
  30. * `socket.on('x', …)` (`tier: 'server→client'`). The in-process
  31. * `.on('x', fn)` ↔ `.emit('x')` pairing stays the emitter pass's.
  32. *
  33. * Every edge is `kind: 'calls'`, `provenance: 'heuristic'`, and carries
  34. * `synthesizedBy`, `channel` (`http` | `queue` | `event` | `socket`), the
  35. * `tier` when the direction is known, the `event` / `queue` / `method` /
  36. * `href` it was paired on, and `registeredAt` — the route registration, the
  37. * decorator, the `.on` — so a reader can check the pairing. Fan-out is capped
  38. * per event as the emitter pass caps it; an HTTP pairing needs no cap because
  39. * it is exact. Test suites and generated files are never sources: a supertest
  40. * call is the test's story, and forty of them would make the route a hub.
  41. */
  42. import type { Edge, Language, Node } from '../types';
  43. import type { ResolutionContext } from './types';
  44. import type { MaybeYield } from './cooperative-yield';
  45. import { stripCommentsForRegex } from './strip-comments';
  46. import { resolveImportPath } from './import-resolver';
  47. import { isGeneratedFile } from '../extraction/generated-detection';
  48. import { isTestPath } from '../search/query-utils';
  49. import { HOLE, readStringAt } from './frameworks/expo-router';
  50. import { enclosingFn, enclosingValue, makeLineAt } from './synth-utils';
  51. const JS_FILE = /\.(?:[cm]?[jt]sx?)$/;
  52. /** Events with more handlers or dispatchers than this are too generic to pair without type information. */
  53. const EVENT_FANOUT_CAP = 6;
  54. const HTTP_VERBS: ReadonlySet<string> = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ALL', 'ANY']);
  55. export const TIER_CLIENT_TO_SERVER = 'client→server';
  56. export const TIER_SERVER_TO_CLIENT = 'server→client';
  57. // =============================================================================
  58. // Source reading
  59. // =============================================================================
  60. /** Index just past the `)` that closes the `(` at `open`, skipping strings; -1 if unbalanced. */
  61. function closeParen(s: string, open: number): number {
  62. let depth = 0;
  63. for (let i = open; i < s.length; i++) {
  64. const ch = s[i];
  65. if (ch === '"' || ch === "'") {
  66. const q = ch;
  67. i++;
  68. while (i < s.length && s[i] !== q) {
  69. if (s[i] === '\\') i++;
  70. i++;
  71. }
  72. continue;
  73. }
  74. if (ch === '`') {
  75. i = templateEnd(s, i);
  76. continue;
  77. }
  78. if (ch === '(') depth++;
  79. else if (ch === ')') {
  80. depth--;
  81. if (depth === 0) return i;
  82. }
  83. }
  84. return -1;
  85. }
  86. /** Index of the backtick closing the template opening at `open`. */
  87. function templateEnd(s: string, open: number): number {
  88. let i = open + 1;
  89. while (i < s.length) {
  90. const ch = s[i];
  91. if (ch === '\\') {
  92. i += 2;
  93. continue;
  94. }
  95. if (ch === '`') return i;
  96. if (ch === '$' && s[i + 1] === '{') {
  97. let depth = 0;
  98. for (i = i + 1; i < s.length; i++) {
  99. if (s[i] === '{') depth++;
  100. else if (s[i] === '}') {
  101. depth--;
  102. if (depth === 0) break;
  103. } else if (s[i] === '`') i = templateEnd(s, i);
  104. }
  105. }
  106. i++;
  107. }
  108. return s.length;
  109. }
  110. /** The arguments of the call whose `(` is at `open`, split at depth-0 commas. */
  111. function argumentsAt(s: string, open: number): string[] | null {
  112. const close = closeParen(s, open);
  113. if (close < 0) return null;
  114. const inner = s.slice(open + 1, close);
  115. const out: string[] = [];
  116. let depth = 0;
  117. let start = 0;
  118. for (let i = 0; i < inner.length; i++) {
  119. const c = inner[i];
  120. if (c === '"' || c === "'") {
  121. const q = c;
  122. i++;
  123. while (i < inner.length && inner[i] !== q) {
  124. if (inner[i] === '\\') i++;
  125. i++;
  126. }
  127. continue;
  128. }
  129. if (c === '`') {
  130. i = templateEnd(inner, i);
  131. continue;
  132. }
  133. if (c === '(' || c === '[' || c === '{') depth++;
  134. else if (c === ')' || c === ']' || c === '}') depth--;
  135. else if (c === ',' && depth === 0) {
  136. out.push(inner.slice(start, i));
  137. start = i + 1;
  138. }
  139. }
  140. out.push(inner.slice(start));
  141. return out.map((a) => a.trim()).filter((a, i) => a.length > 0 || i === 0);
  142. }
  143. /** The first string literal in `text`, holes kept; null when it opens with anything else. */
  144. function leadingString(text: string): string | null {
  145. const t = text.trim().replace(/^\(\s*/, '');
  146. // `new URL('/x', base)` — the path is the first argument of the URL.
  147. const url = /^new\s+URL\s*\(/.exec(t);
  148. if (url) {
  149. const args = argumentsAt(t, url[0].length - 1);
  150. return args && args[0] ? leadingString(args[0]) : null;
  151. }
  152. if (t[0] === '"' || t[0] === "'" || t[0] === '`') return readStringAt(t, 0);
  153. return null;
  154. }
  155. /** `method: 'POST'` inside an options object / config, upper-cased; null when absent or computed. */
  156. function methodIn(text: string): string | null {
  157. const m = /\bmethod\s*:\s*(['"`])([A-Za-z]+)\1/.exec(text);
  158. return m ? m[2]!.toUpperCase() : null;
  159. }
  160. /** The first string literal anywhere in a decorator's arguments (`'x'`, `{ name: 'x' }`). */
  161. function firstLiteral(args: string): string | null {
  162. const m = /(['"`])([^'"`]+)\1/.exec(args);
  163. return m ? m[2]! : null;
  164. }
  165. /** Every string literal in a decorator's arguments, for `@OnEvent(['a', 'b'])`. */
  166. function allLiterals(args: string): string[] {
  167. const out: string[] = [];
  168. const re = /(['"`])([^'"`]+)\1/g;
  169. let m: RegExpExecArray | null;
  170. while ((m = re.exec(args)) !== null) out.push(m[2]!);
  171. return out;
  172. }
  173. /**
  174. * The name of the method a decorator sits on: skip further stacked decorators
  175. * and modifiers after the decorator's `)`, then take the identifier before `(`.
  176. */
  177. function methodNameAfter(safe: string, from: number): { name: string; index: number } | null {
  178. let i = from;
  179. const ws = /\s*/y;
  180. const deco = /@[\w.]+/y;
  181. const modifier = /(?:public|private|protected|async|static|readonly|override)\b/y;
  182. const ident = /([A-Za-z_$][\w$]*)\s*[<(]/y;
  183. const eat = (): void => {
  184. ws.lastIndex = i;
  185. if (ws.exec(safe)) i = ws.lastIndex;
  186. };
  187. for (;;) {
  188. eat();
  189. if (safe[i] !== '@') break;
  190. deco.lastIndex = i;
  191. if (!deco.exec(safe)) break;
  192. i = deco.lastIndex;
  193. eat();
  194. if (safe[i] === '(') {
  195. const close = closeParen(safe, i);
  196. if (close < 0) return null;
  197. i = close + 1;
  198. }
  199. }
  200. for (;;) {
  201. eat();
  202. modifier.lastIndex = i;
  203. if (modifier.exec(safe) && modifier.lastIndex > i) {
  204. i = modifier.lastIndex;
  205. continue;
  206. }
  207. break;
  208. }
  209. eat();
  210. ident.lastIndex = i;
  211. const m = ident.exec(safe);
  212. return m ? { name: m[1]!, index: i } : null;
  213. }
  214. /** Every `@Name(` decorator in `safe` with its arguments and where it ends. */
  215. function decorators(safe: string, name: string): Array<{ args: string; index: number; end: number }> {
  216. const out: Array<{ args: string; index: number; end: number }> = [];
  217. const re = new RegExp(`@${name}\\s*\\(`, 'g');
  218. let m: RegExpExecArray | null;
  219. while ((m = re.exec(safe)) !== null) {
  220. const open = m.index + m[0].length - 1;
  221. const close = closeParen(safe, open);
  222. if (close < 0) continue;
  223. out.push({ args: safe.slice(open + 1, close), index: m.index, end: close + 1 });
  224. re.lastIndex = close + 1;
  225. }
  226. return out;
  227. }
  228. // =============================================================================
  229. // Per-file facts, read once
  230. // =============================================================================
  231. interface FileFacts {
  232. file: string;
  233. safe: string;
  234. nodes: Node[];
  235. lineOf: (idx: number) => number;
  236. /** The 0-based column of an index on its line — where the site reader looks for the call. */
  237. columnOf: (idx: number) => number;
  238. /** Lines a framework resolver made a route node on — registrations, never client calls. */
  239. routeLines: Set<number>;
  240. /** Local names bound to an HTTP client instance, with their literal base URL when written. */
  241. clients: Map<string, { baseURL: string | null }>;
  242. /** The module's default export is a client instance. */
  243. defaultClient: { baseURL: string | null } | null;
  244. /** Local names bound to a named queue (`new Queue('email')`, `@InjectQueue('email') x`). */
  245. queues: Map<string, string>;
  246. /** The file holds a socket server (a gateway, `io.on('connection')`, `new Server(…)`). */
  247. socketServer: boolean;
  248. }
  249. const CLIENT_FACTORY =
  250. /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*?)?=\s*(?:await\s+)?(?:(?:axios|ky|got|ofetch|\$fetch|wretch|redaxios)\s*\.\s*(?:create|extend)|new\s+Axios|wretch)\s*\(/g;
  251. const DEFAULT_CLIENT_FACTORY = /\bexport\s+default\s+(?:(?:axios|ky|got|ofetch|\$fetch|wretch|redaxios)\s*\.\s*(?:create|extend)|new\s+Axios|wretch)\s*\(/;
  252. const QUEUE_BINDING =
  253. /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*?)?=\s*(?:new\s+)?(?:Queue|Bull|BullQueue)\s*(?:<[^>]*>)?\s*\(\s*(['"`])([^'"`]+)\2/g;
  254. const INJECT_QUEUE = /@InjectQueue\s*\(\s*(['"`])([^'"`]+)\1\s*\)\s*(?:(?:private|public|protected|readonly)\s+)*([A-Za-z_$][\w$]*)/g;
  255. const SOCKET_SERVER_FILE = /@WebSocketGateway\s*\(|@SubscribeMessage\s*\(|@WebSocketServer\s*\(|\bnew\s+(?:Server|SocketIOServer|WebSocketServer|WebSocket\.Server|WSServer)\b|\.on\s*\(\s*['"]connection['"]/;
  256. function baseUrlIn(safe: string, open: number): string | null {
  257. const args = argumentsAt(safe, open);
  258. const config = args?.[0] ?? '';
  259. const m = /\b(?:baseURL|baseUrl|prefixUrl|baseURI)\s*:\s*/.exec(config);
  260. if (!m) return null;
  261. return readStringAt(config, m.index + m[0].length);
  262. }
  263. function readFacts(ctx: ResolutionContext, file: string): FileFacts | null {
  264. const content = ctx.readFile(file);
  265. if (!content) return null;
  266. const safe = stripCommentsForRegex(content, 'typescript');
  267. const nodes = ctx.getNodesInFile(file);
  268. const routeLines = new Set<number>();
  269. for (const n of nodes) if (n.kind === 'route') routeLines.add(n.startLine);
  270. const clients = new Map<string, { baseURL: string | null }>();
  271. CLIENT_FACTORY.lastIndex = 0;
  272. let m: RegExpExecArray | null;
  273. while ((m = CLIENT_FACTORY.exec(safe)) !== null) {
  274. clients.set(m[1]!, { baseURL: baseUrlIn(safe, m.index + m[0].length - 1) });
  275. }
  276. const dm = DEFAULT_CLIENT_FACTORY.exec(safe);
  277. const defaultClient = dm ? { baseURL: baseUrlIn(safe, dm.index + dm[0].length - 1) } : null;
  278. const queues = new Map<string, string>();
  279. QUEUE_BINDING.lastIndex = 0;
  280. while ((m = QUEUE_BINDING.exec(safe)) !== null) queues.set(m[1]!, m[3]!);
  281. INJECT_QUEUE.lastIndex = 0;
  282. while ((m = INJECT_QUEUE.exec(safe)) !== null) queues.set(m[3]!, m[2]!);
  283. return {
  284. file,
  285. safe,
  286. nodes,
  287. lineOf: makeLineAt(safe, 1),
  288. columnOf: (idx: number) => idx - (safe.lastIndexOf('\n', idx - 1) + 1),
  289. routeLines,
  290. clients,
  291. defaultClient,
  292. queues,
  293. socketServer: SOCKET_SERVER_FILE.test(safe),
  294. };
  295. }
  296. /** Facts for the file a local name is imported from, when the import resolves to a project file. */
  297. function importedFacts(
  298. ctx: ResolutionContext,
  299. facts: FileFacts,
  300. localName: string,
  301. cache: Map<string, FileFacts | null>
  302. ): { facts: FileFacts; exportedName: string; isDefault: boolean } | null {
  303. const lang: Language = facts.file.endsWith('x') ? 'tsx' : 'typescript';
  304. const im = ctx.getImportMappings(facts.file, lang).find((i) => i.localName === localName);
  305. if (!im) return null;
  306. // The mappings name the module as written; the file it is comes from the
  307. // same resolution the import resolver uses (aliases, extensions, index files).
  308. const resolved = im.resolvedPath ?? resolveImportPath(im.source, facts.file, lang, ctx);
  309. if (!resolved) return null;
  310. let target = cache.get(resolved);
  311. if (target === undefined) {
  312. target = JS_FILE.test(resolved) ? readFacts(ctx, resolved) : null;
  313. cache.set(resolved, target);
  314. }
  315. return target ? { facts: target, exportedName: im.exportedName, isDefault: im.isDefault } : null;
  316. }
  317. // =============================================================================
  318. // 1. HTTP client → route
  319. // =============================================================================
  320. /** A receiver that is an HTTP client by name alone. */
  321. const CLIENT_NAMES =
  322. /^(?:axios|ky|got|superagent|http|https|httpClient|httpService|api|apiClient|client|restClient|request|agent|fetcher|instance|\$api|\$http|\$axios|axiosInstance|Axios|HttpClient|backend|server)$/;
  323. /** A receiver that registers routes, never a client — unless it was made by a client factory. */
  324. const SERVER_NAMES = /^(?:app|router|route|routes|express|fastify|koa|hono|elysia|apiRouter|v1|v2|r)$/;
  325. /** A type argument between the callee and its `(` — `useSWR<TeamData>('/api/team')`, `ky.get<User>('/x')`. */
  326. const GENERIC = String.raw`(?:<[^()<>]*(?:<[^()<>]*>[^()<>]*)*>)?`;
  327. const BARE_CLIENT_CALL = new RegExp(String.raw`(?:(?:window|globalThis|global)\s*\.\s*)?\b(fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\s*${GENERIC}\s*\(`, 'g');
  328. const MEMBER_CLIENT_CALL = new RegExp(
  329. String.raw`((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*(get|post|put|patch|delete|head|options|request|\$get|\$post|\$put|\$patch|\$delete)\s*${GENERIC}\s*\(`,
  330. 'g'
  331. );
  332. interface HttpRoute {
  333. node: Node;
  334. method: string;
  335. segs: string[];
  336. }
  337. interface HttpSite {
  338. fn: Node;
  339. file: string;
  340. line: number;
  341. column: number;
  342. /** The call as written, `fetch` / `api.get` — what the Steps walk must not also draw as an effect. */
  343. callee: string;
  344. method: string;
  345. segs: string[];
  346. /** The path began with a hole — a base URL — and matches a route by its tail. */
  347. suffix: boolean;
  348. display: string;
  349. }
  350. function httpRoutes(ctx: ResolutionContext): HttpRoute[] {
  351. const out: HttpRoute[] = [];
  352. for (const node of ctx.getNodesByKind('route')) {
  353. const space = node.name.indexOf(' ');
  354. if (space <= 0) continue;
  355. const method = node.name.slice(0, space).toUpperCase();
  356. if (!HTTP_VERBS.has(method)) continue;
  357. const path = node.name.slice(space + 1).trim();
  358. if (!path.startsWith('/')) continue;
  359. out.push({ node, method, segs: path.split('/').filter((s) => s.length > 0) });
  360. }
  361. return out;
  362. }
  363. const PARAM_SEG = /^(?::|\{|\[|<|\*)|\?$/;
  364. const CATCH_ALL = /^(?:\*|\[\.\.\.|\{\*|:[\w$]+\*$|\{[\w$]+:\*\}|\*[\w$]*$)/;
  365. /** How well the client's segments match a route's; null when they do not. A literal match beats a parameter's. */
  366. function scorePath(client: readonly string[], route: readonly string[]): number | null {
  367. let score = 0;
  368. let i = 0;
  369. for (let r = 0; r < route.length; r++) {
  370. const seg = route[r]!;
  371. if (CATCH_ALL.test(seg)) {
  372. if (i >= client.length) return null;
  373. score += client.length - i;
  374. i = client.length;
  375. continue;
  376. }
  377. if (i >= client.length) return null;
  378. const c = client[i]!;
  379. // A hole (`${id}`) fills a route's parameter; it never stands in for a
  380. // literal segment — `/api/products/${id}` is not `/api/products/top`.
  381. if (PARAM_SEG.test(seg)) score += 2;
  382. else if (c === seg) score += 3;
  383. else return null;
  384. i++;
  385. }
  386. return i === client.length ? score : null;
  387. }
  388. function matchHttp(site: HttpSite, routes: readonly HttpRoute[]): HttpRoute | null {
  389. let best: HttpRoute | null = null;
  390. let bestScore = -1;
  391. let tied = false;
  392. for (const r of routes) {
  393. if (r.method !== 'ALL' && r.method !== 'ANY' && r.method !== site.method) continue;
  394. let score: number | null;
  395. if (site.suffix) {
  396. // A base URL hides a prefix the route may spell out (`${API}/users` for
  397. // `GET /api/users`) — but a one-segment tail names half the routes in
  398. // an index, and a long hidden prefix is a different API. Two segments
  399. // of tail at least, two of prefix at most.
  400. if (site.segs.length < 2 || r.segs.length < site.segs.length || r.segs.length - site.segs.length > 2) continue;
  401. score = scorePath(site.segs, r.segs.slice(r.segs.length - site.segs.length));
  402. } else score = scorePath(site.segs, r.segs);
  403. if (score === null) continue;
  404. if (score > bestScore) {
  405. best = r;
  406. bestScore = score;
  407. tied = false;
  408. } else if (score === bestScore) tied = true;
  409. }
  410. return tied ? null : best;
  411. }
  412. /**
  413. * The path a client call names, as segments, with `${…}` as `*`; `suffix`
  414. * when a base URL came first. Null when the path is not literal enough: a
  415. * relative path with no base, or nothing but holes.
  416. */
  417. function clientPath(raw: string, baseURL: string | null): { segs: string[]; suffix: boolean; display: string } | null {
  418. let p = raw;
  419. const cut = p.search(/[?#]/);
  420. if (cut >= 0) p = p.slice(0, cut);
  421. let suffix = false;
  422. const absolute = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*(\/.*)?$/i.exec(p);
  423. if (absolute) p = absolute[1] ?? '/';
  424. else if (!p.startsWith('/')) {
  425. if (p.startsWith(HOLE)) {
  426. const rest = p.slice(1);
  427. if (!rest.startsWith('/')) return null;
  428. p = rest;
  429. suffix = true;
  430. } else if (baseURL !== null) {
  431. const base = clientPath(baseURL, null);
  432. if (!base) {
  433. // A base that is itself a hole: match by the tail.
  434. if (!baseURL.includes(HOLE)) return null;
  435. suffix = true;
  436. p = '/' + p;
  437. } else {
  438. suffix = base.suffix;
  439. p = '/' + [...base.segs, ...p.split('/')].filter(Boolean).join('/');
  440. }
  441. } else return null;
  442. } else if (baseURL !== null) {
  443. // An instance with a literal path base: axios joins `baseURL + url`.
  444. const base = clientPath(baseURL, null);
  445. if (base && base.segs.length > 0) {
  446. suffix = base.suffix;
  447. p = '/' + [...base.segs, ...p.split('/')].filter(Boolean).join('/');
  448. } else if (!base && baseURL.includes(HOLE)) suffix = true;
  449. }
  450. const segs = p
  451. .split('/')
  452. .filter((s) => s.length > 0)
  453. .map((s) => (s.includes(HOLE) ? '*' : s));
  454. if (segs.length > 0 && segs.every((s) => s === '*')) return null;
  455. return { segs, suffix, display: '/' + segs.map((s) => (s === '*' ? '${…}' : s)).join('/') };
  456. }
  457. /** What a member-call receiver is: a client (with its base URL), or nothing. */
  458. function clientFor(
  459. ctx: ResolutionContext,
  460. facts: FileFacts,
  461. receiver: string,
  462. cache: Map<string, FileFacts | null>
  463. ): { baseURL: string | null } | null {
  464. const chain = receiver.replace(/\s+/g, '').replace(/^this\./, '').split('.');
  465. const head = chain[0]!;
  466. const last = chain[chain.length - 1]!;
  467. const local = facts.clients.get(head);
  468. if (local) return local;
  469. const imported = importedFacts(ctx, facts, head, cache);
  470. if (imported) {
  471. const bound = imported.isDefault ? imported.facts.defaultClient : imported.facts.clients.get(imported.exportedName) ?? null;
  472. if (bound) return bound;
  473. }
  474. if (SERVER_NAMES.test(last) || SERVER_NAMES.test(head)) return null;
  475. if (CLIENT_NAMES.test(last)) return { baseURL: null };
  476. return null;
  477. }
  478. function collectHttpSites(ctx: ResolutionContext, facts: FileFacts, sites: HttpSite[], cache: Map<string, FileFacts | null>): void {
  479. const { safe, nodes, lineOf } = facts;
  480. const add = (index: number, open: number, verb: string | null, baseURL: string | null): void => {
  481. const line = lineOf(index);
  482. const callee = safe.slice(index, open).replace(/\s+/g, '').replace(/<.*>$/, '');
  483. if (facts.routeLines.has(line)) return; // a registration the resolver already read
  484. const fn = enclosingFn(nodes, line);
  485. if (!fn) return;
  486. const args = argumentsAt(safe, open);
  487. if (!args || !args[0]) return;
  488. let first = args[0];
  489. let method = verb;
  490. // `axios({ url, method })`, `.request({ url, method })`, `ky(url, { method })`.
  491. if (first.trimStart().startsWith('{')) {
  492. const url = /\burl\s*:\s*/.exec(first);
  493. if (!url) return;
  494. method = method ?? methodIn(first) ?? 'GET';
  495. first = first.slice(url.index + url[0].length);
  496. } else if (method === null) {
  497. method = methodIn(args.slice(1).join(',')) ?? 'GET';
  498. }
  499. const literal = leadingString(first);
  500. if (literal === null) return;
  501. const path = clientPath(literal, baseURL);
  502. if (!path) return;
  503. sites.push({ fn, file: facts.file, line, column: facts.columnOf(index), callee, method, segs: path.segs, suffix: path.suffix, display: path.display });
  504. };
  505. BARE_CLIENT_CALL.lastIndex = 0;
  506. let m: RegExpExecArray | null;
  507. while ((m = BARE_CLIENT_CALL.exec(safe)) !== null) {
  508. // `this.fetch(…)` / `repo.fetch(…)` is a project method, not the platform's.
  509. const before = safe[m.index - 1];
  510. if (before === '.' && !/^(?:window|globalThis|global)\s*\./.test(m[0])) continue;
  511. add(m.index, m.index + m[0].length - 1, null, null);
  512. }
  513. MEMBER_CLIENT_CALL.lastIndex = 0;
  514. while ((m = MEMBER_CLIENT_CALL.exec(safe)) !== null) {
  515. const client = clientFor(ctx, facts, m[1]!, cache);
  516. if (!client) continue;
  517. const verb = m[2]!.replace(/^\$/, '').toUpperCase();
  518. add(m.index, m.index + m[0].length - 1, verb === 'REQUEST' ? null : verb, client.baseURL);
  519. }
  520. }
  521. // =============================================================================
  522. // 2. Queue job → consumer
  523. // =============================================================================
  524. const QUEUE_ADD = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*add\s*\(\s*(['"`])([^'"`]+)\2/g;
  525. const QUEUE_SHAPED = /queue|jobs?$|worker|bull|flow|producer/i;
  526. const NEW_WORKER = /\bnew\s+Worker\s*(?:<[^>]*>)?\s*\(\s*(['"`])([^'"`]+)\1\s*,\s*/g;
  527. const QUEUE_PROCESS = /((?:this\s*\.\s*)?[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*process\s*\(\s*(?:(['"`])([^'"`]+)\2\s*,\s*)?(?:\d+\s*,\s*)?/g;
  528. /** A handler argument: a named function (group 1), or an inline function. */
  529. const HANDLER_ARG = /^(?:(?:async\s+)?([A-Za-z_$][\w$.]*)\s*(?:[,)]|$)|(?:async\s*)?(?:\(|function\b|[A-Za-z_$][\w$]*\s*=>))/;
  530. interface QueueProducer {
  531. fn: Node;
  532. file: string;
  533. line: number;
  534. column: number;
  535. callee: string;
  536. queue: string | null;
  537. job: string;
  538. }
  539. interface QueueConsumer {
  540. node: Node;
  541. file: string;
  542. line: number;
  543. queue: string | null;
  544. /** Null: every job on the queue (`@Process()` with no name, a WorkerHost's `process`, `new Worker`). */
  545. job: string | null;
  546. }
  547. /** The queue a receiver is bound to, by its binding in this file or the file it is imported from. */
  548. function queueFor(ctx: ResolutionContext, facts: FileFacts, receiver: string, cache: Map<string, FileFacts | null>): string | null {
  549. const chain = receiver.replace(/\s+/g, '').replace(/^this\./, '').split('.');
  550. const head = chain[0]!;
  551. const own = facts.queues.get(head);
  552. if (own) return own;
  553. const imported = importedFacts(ctx, facts, head, cache);
  554. if (imported) {
  555. const bound = imported.facts.queues.get(imported.exportedName);
  556. if (bound) return bound;
  557. }
  558. return null;
  559. }
  560. /** The function a handler argument names or encloses; null when it is neither. */
  561. function handlerNode(ctx: ResolutionContext, facts: FileFacts, text: string, line: number, cache: Map<string, FileFacts | null>): Node | null {
  562. const m = HANDLER_ARG.exec(text.trimStart());
  563. if (!m) return null;
  564. if (m[1]) {
  565. const name = m[1].split('.').pop()!;
  566. const candidates = ctx.getNodesByName(name).filter((n) => n.kind === 'function' || n.kind === 'method');
  567. const local = candidates.filter((n) => n.filePath === facts.file);
  568. if (local.length === 1) return local[0]!;
  569. if (local.length > 1) return null;
  570. const imported = importedFacts(ctx, facts, m[1].split('.')[0]!, cache);
  571. if (imported) {
  572. const viaImport = candidates.filter((n) => n.filePath === imported.facts.file);
  573. if (viaImport.length === 1) return viaImport[0]!;
  574. }
  575. return candidates.length === 1 ? candidates[0]! : null;
  576. }
  577. return enclosingFn(facts.nodes, line) ?? enclosingValue(facts.nodes, line);
  578. }
  579. /** The nearest class declared at or after `index`, as its node. */
  580. function classAfter(facts: FileFacts, index: number): Node | null {
  581. const m = /\bclass\s+([A-Za-z_$][\w$]*)/g;
  582. m.lastIndex = index;
  583. const hit = m.exec(facts.safe);
  584. if (!hit) return null;
  585. const line = facts.lineOf(hit.index);
  586. return facts.nodes.find((n) => n.kind === 'class' && n.name === hit[1] && n.startLine >= line - 2) ?? null;
  587. }
  588. /** The method node a decorator at `end` sits on, inside `cls` when given. */
  589. function decoratedMethod(facts: FileFacts, end: number, cls: Node | null): Node | null {
  590. const named = methodNameAfter(facts.safe, end);
  591. if (!named) return null;
  592. const line = facts.lineOf(named.index);
  593. return (
  594. facts.nodes.find(
  595. (n) =>
  596. (n.kind === 'method' || n.kind === 'function') &&
  597. n.name === named.name &&
  598. n.startLine >= line - 1 &&
  599. n.startLine <= line + 1 &&
  600. (!cls || (n.startLine >= cls.startLine && n.endLine <= cls.endLine))
  601. ) ?? null
  602. );
  603. }
  604. function collectQueue(ctx: ResolutionContext, facts: FileFacts, producers: QueueProducer[], consumers: QueueConsumer[], cache: Map<string, FileFacts | null>): void {
  605. const { safe, nodes, lineOf } = facts;
  606. let m: RegExpExecArray | null;
  607. QUEUE_ADD.lastIndex = 0;
  608. while ((m = QUEUE_ADD.exec(safe)) !== null) {
  609. const receiver = m[1]!;
  610. const queue = queueFor(ctx, facts, receiver, cache);
  611. const last = receiver.replace(/\s+/g, '').split('.').pop()!;
  612. if (queue === null && !QUEUE_SHAPED.test(last)) continue;
  613. const line = lineOf(m.index);
  614. const fn = enclosingFn(nodes, line);
  615. if (!fn) continue;
  616. producers.push({ fn, file: facts.file, line, column: facts.columnOf(m.index), callee: `${receiver.replace(/\s+/g, '')}.add`, queue, job: m[3]! });
  617. }
  618. // Nest: `@Processor('email')` on a class; `@Process('welcome')` on its methods,
  619. // or a WorkerHost's `process(job)`.
  620. for (const proc of decorators(safe, 'Processor')) {
  621. const queue = firstLiteral(proc.args);
  622. const cls = classAfter(facts, proc.end);
  623. if (!cls) continue;
  624. let any = false;
  625. for (const job of decorators(safe, 'Process')) {
  626. const line = lineOf(job.index);
  627. if (line < cls.startLine || line > cls.endLine) continue;
  628. const method = decoratedMethod(facts, job.end, cls);
  629. if (!method) continue;
  630. any = true;
  631. consumers.push({ node: method, file: facts.file, line, queue, job: firstLiteral(job.args) });
  632. }
  633. if (!any) {
  634. const process = nodes.find((n) => n.kind === 'method' && n.name === 'process' && n.startLine >= cls.startLine && n.endLine <= cls.endLine);
  635. if (process) consumers.push({ node: process, file: facts.file, line: process.startLine, queue, job: null });
  636. }
  637. }
  638. // BullMQ: `new Worker('email', handler)`.
  639. NEW_WORKER.lastIndex = 0;
  640. while ((m = NEW_WORKER.exec(safe)) !== null) {
  641. const line = lineOf(m.index);
  642. const node = handlerNode(ctx, facts, safe.slice(m.index + m[0].length, m.index + m[0].length + 200), line, cache);
  643. if (!node) continue;
  644. consumers.push({ node, file: facts.file, line, queue: m[2]!, job: null });
  645. }
  646. // Bull: `queue.process('welcome', handler)` / `queue.process(handler)`.
  647. QUEUE_PROCESS.lastIndex = 0;
  648. while ((m = QUEUE_PROCESS.exec(safe)) !== null) {
  649. const receiver = m[1]!;
  650. const queue = queueFor(ctx, facts, receiver, cache);
  651. const last = receiver.replace(/\s+/g, '').split('.').pop()!;
  652. if (queue === null && !QUEUE_SHAPED.test(last)) continue;
  653. const line = lineOf(m.index);
  654. const node = handlerNode(ctx, facts, safe.slice(m.index + m[0].length, m.index + m[0].length + 200), line, cache);
  655. if (!node) continue;
  656. consumers.push({ node, file: facts.file, line, queue, job: m[3] ?? null });
  657. }
  658. }
  659. function pairQueue(producers: readonly QueueProducer[], consumers: readonly QueueConsumer[], edges: Edge[], seen: Set<string>): void {
  660. for (const p of producers) {
  661. let candidates = consumers.filter((c) => (p.queue === null || c.queue === null || c.queue === p.queue) && (c.job === null || c.job === p.job));
  662. // The most specific pairing wins: the job by name on the named queue,
  663. // then the job by name, then the queue's default consumer.
  664. const exact = candidates.filter((c) => c.job === p.job && c.queue === p.queue && p.queue !== null);
  665. if (exact.length > 0) candidates = exact;
  666. else {
  667. const byJob = candidates.filter((c) => c.job === p.job);
  668. if (byJob.length > 0) candidates = byJob;
  669. else if (p.queue === null) continue; // an unnamed queue and no consumer naming the job: a guess
  670. else candidates = candidates.filter((c) => c.queue === p.queue);
  671. }
  672. if (candidates.length === 0 || candidates.length > EVENT_FANOUT_CAP) continue;
  673. for (const c of candidates) {
  674. if (c.node.id === p.fn.id) continue;
  675. const key = `${p.fn.id}>${c.node.id}`;
  676. if (seen.has(key)) continue;
  677. seen.add(key);
  678. edges.push({
  679. source: p.fn.id,
  680. target: c.node.id,
  681. kind: 'calls',
  682. line: p.line,
  683. column: p.column,
  684. provenance: 'heuristic',
  685. metadata: {
  686. synthesizedBy: 'queue-job',
  687. channel: 'queue',
  688. callee: p.callee,
  689. event: p.job,
  690. ...(p.queue ?? c.queue ? { queue: p.queue ?? c.queue } : {}),
  691. registeredAt: `${c.file}:${c.line}`,
  692. },
  693. });
  694. }
  695. }
  696. }
  697. // =============================================================================
  698. // 3. Events: a bus, and sockets both ways
  699. // =============================================================================
  700. const EMIT = /((?:[\w$]+(?:\([^()]*\))?\s*\.\s*)*[\w$]+)\s*\.\s*(emit|emitAsync)\s*\(\s*(['"`])([^'"`\n]+)\3/g;
  701. const SOCKET_ON = /((?:[\w$]+(?:\([^()]*\))?\s*\.\s*)*[\w$]+)\s*\.\s*(?:on|once)\s*\(\s*(['"`])([^'"`\n]+)\2\s*,\s*/g;
  702. const SOCKET_WORDS = /^(?:socket|io|ws|wss|client|server|namespace|nsp|conn|connection|gateway|broadcast|to|in|of|except|volatile|local|sockets|socketServer|wsServer|room|channel|pusher|ably|ioClient|socketClient|sock)$/;
  703. const BUS_WORDS = /^(?:eventEmitter|emitter|events|eventBus|bus|dispatcher|pubsub|publisher|eventPublisher|ee|hub|mediator|broker|messageBus|appEvents|domainEvents|eventsService|eventService)$/;
  704. /** The transport's own events — every socket emits and handles them; pairing them says nothing. */
  705. const GENERIC_EVENT =
  706. /^(?:error|connect|connect_error|connect_failed|connection|disconnect|disconnecting|reconnect|reconnect_attempt|reconnecting|reconnect_error|reconnect_failed|close|open|end|data|ready|drain|finish|pipe|unpipe|listening|timeout|ping|pong|upgrade|newListener|removeListener)$/;
  707. interface Dispatch {
  708. fn: Node;
  709. file: string;
  710. line: number;
  711. column: number;
  712. callee: string;
  713. event: string;
  714. shape: 'bus' | 'socket';
  715. side: 'server' | 'client';
  716. }
  717. interface Handler {
  718. node: Node;
  719. file: string;
  720. line: number;
  721. /** An event name, or an `@OnEvent` glob. */
  722. pattern: string;
  723. kind: 'bus' | 'socket';
  724. side: 'server' | 'client';
  725. }
  726. function shapeOf(receiver: string): 'bus' | 'socket' | null {
  727. const segs = receiver.replace(/\([^()]*\)/g, '').replace(/\s+/g, '').split('.').filter((s) => s !== 'this');
  728. if (segs.some((s) => SOCKET_WORDS.test(s))) return 'socket';
  729. if (segs.some((s) => BUS_WORDS.test(s))) return 'bus';
  730. return null;
  731. }
  732. function collectEvents(ctx: ResolutionContext, facts: FileFacts, dispatches: Dispatch[], handlers: Handler[], cache: Map<string, FileFacts | null>): void {
  733. const { safe, nodes, lineOf } = facts;
  734. const side: 'server' | 'client' = facts.socketServer ? 'server' : 'client';
  735. let m: RegExpExecArray | null;
  736. EMIT.lastIndex = 0;
  737. while ((m = EMIT.exec(safe)) !== null) {
  738. const shape = shapeOf(m[1]!);
  739. if (!shape || GENERIC_EVENT.test(m[4]!)) continue;
  740. const line = lineOf(m.index);
  741. const fn = enclosingFn(nodes, line);
  742. if (!fn) continue;
  743. dispatches.push({ fn, file: facts.file, line, column: facts.columnOf(m.index), callee: `${m[1]!.replace(/\s+/g, '')}.${m[2]!}`, event: m[4]!, shape, side });
  744. }
  745. for (const d of decorators(safe, 'OnEvent')) {
  746. const method = decoratedMethod(facts, d.end, null);
  747. if (!method) continue;
  748. const patterns = d.args.trimStart().startsWith('[') ? allLiterals(d.args) : [firstLiteral(d.args)].filter((x): x is string => x !== null);
  749. for (const pattern of patterns) handlers.push({ node: method, file: facts.file, line: lineOf(d.index), pattern, kind: 'bus', side });
  750. }
  751. for (const d of decorators(safe, 'SubscribeMessage')) {
  752. const method = decoratedMethod(facts, d.end, null);
  753. const event = firstLiteral(d.args);
  754. if (!method || event === null) continue;
  755. handlers.push({ node: method, file: facts.file, line: lineOf(d.index), pattern: event, kind: 'socket', side: 'server' });
  756. }
  757. SOCKET_ON.lastIndex = 0;
  758. while ((m = SOCKET_ON.exec(safe)) !== null) {
  759. if (shapeOf(m[1]!) !== 'socket' || GENERIC_EVENT.test(m[3]!)) continue;
  760. const line = lineOf(m.index);
  761. const node = handlerNode(ctx, facts, safe.slice(m.index + m[0].length, m.index + m[0].length + 200), line, cache);
  762. if (!node) continue;
  763. handlers.push({ node, file: facts.file, line, pattern: m[3]!, kind: 'socket', side });
  764. }
  765. }
  766. /** `user.*` matches one segment, `**` any; anything else is exact. */
  767. function eventMatches(pattern: string, event: string): boolean {
  768. if (pattern === event) return true;
  769. if (!pattern.includes('*')) return false;
  770. const re = new RegExp('^' + pattern.split('**').map((part) => part.split('*').map(escapeRe).join('[^.]+')).join('.*') + '$');
  771. return re.test(event);
  772. }
  773. function escapeRe(s: string): string {
  774. return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  775. }
  776. function pairEvents(dispatches: readonly Dispatch[], handlers: readonly Handler[], edges: Edge[], seen: Set<string>): void {
  777. // Fan-out is judged per event name on each side, as the emitter pass does.
  778. const dispatchesByEvent = new Map<string, Dispatch[]>();
  779. for (const d of dispatches) dispatchesByEvent.set(`${d.shape}:${d.event}`, [...(dispatchesByEvent.get(`${d.shape}:${d.event}`) ?? []), d]);
  780. for (const [, group] of dispatchesByEvent) {
  781. if (group.length > EVENT_FANOUT_CAP) continue;
  782. for (const d of group) {
  783. let matched: Handler[];
  784. let tier: string | null = null;
  785. if (d.shape === 'bus') matched = handlers.filter((h) => h.kind === 'bus' && eventMatches(h.pattern, d.event));
  786. else if (d.side === 'client') {
  787. matched = handlers.filter((h) => h.kind === 'socket' && h.side === 'server' && h.pattern === d.event);
  788. tier = TIER_CLIENT_TO_SERVER;
  789. } else {
  790. matched = handlers.filter((h) => h.kind === 'socket' && h.side === 'client' && h.pattern === d.event);
  791. tier = TIER_SERVER_TO_CLIENT;
  792. }
  793. if (matched.length === 0 || matched.length > EVENT_FANOUT_CAP) continue;
  794. for (const h of matched) {
  795. if (h.node.id === d.fn.id) continue;
  796. const key = `${d.fn.id}>${h.node.id}`;
  797. if (seen.has(key)) continue;
  798. seen.add(key);
  799. edges.push({
  800. source: d.fn.id,
  801. target: h.node.id,
  802. kind: 'calls',
  803. line: d.line,
  804. column: d.column,
  805. provenance: 'heuristic',
  806. metadata: {
  807. synthesizedBy: 'event-bus',
  808. channel: d.shape === 'bus' ? 'event' : 'socket',
  809. callee: d.callee,
  810. event: d.event,
  811. ...(tier ? { tier } : {}),
  812. registeredAt: `${h.file}:${h.line}`,
  813. },
  814. });
  815. }
  816. }
  817. }
  818. }
  819. // =============================================================================
  820. // The pass
  821. // =============================================================================
  822. const HTTP_GATE = /\b(?:fetch|\$fetch|ofetch|axios|ky|got|useFetch|useSWR)\b|\.\s*(?:get|post|put|patch|delete|head|options|request|\$get|\$post)\s*[<(]/;
  823. const QUEUE_GATE = /\.\s*add\s*\(|@Processor\s*\(|\bnew\s+Worker\s*[<(]|\.\s*process\s*\(/;
  824. const EVENT_GATE = /\.\s*(?:emit|emitAsync|on|once)\s*\(|@OnEvent\s*\(|@SubscribeMessage\s*\(/;
  825. export async function crossTierEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
  826. const routes = httpRoutes(ctx);
  827. const httpSites: HttpSite[] = [];
  828. const producers: QueueProducer[] = [];
  829. const consumers: QueueConsumer[] = [];
  830. const dispatches: Dispatch[] = [];
  831. const handlers: Handler[] = [];
  832. const cache = new Map<string, FileFacts | null>();
  833. let scanned = 0;
  834. for (const file of ctx.getAllFiles()) {
  835. if (!JS_FILE.test(file) || isTestPath(file) || isGeneratedFile(file)) continue;
  836. if ((++scanned & 63) === 0) await onYield();
  837. const content = ctx.readFile(file);
  838. if (!content) continue;
  839. const wantsHttp = routes.length > 0 && HTTP_GATE.test(content);
  840. const wantsQueue = QUEUE_GATE.test(content);
  841. const wantsEvents = EVENT_GATE.test(content);
  842. if (!wantsHttp && !wantsQueue && !wantsEvents) continue;
  843. let facts = cache.get(file);
  844. if (facts === undefined) {
  845. facts = readFacts(ctx, file);
  846. cache.set(file, facts);
  847. }
  848. if (!facts) continue;
  849. if (wantsHttp) collectHttpSites(ctx, facts, httpSites, cache);
  850. if (wantsQueue) collectQueue(ctx, facts, producers, consumers, cache);
  851. if (wantsEvents) collectEvents(ctx, facts, dispatches, handlers, cache);
  852. }
  853. const edges: Edge[] = [];
  854. const seen = new Set<string>();
  855. for (const site of httpSites) {
  856. const route = matchHttp(site, routes);
  857. if (!route || route.node.id === site.fn.id) continue;
  858. const key = `${site.fn.id}>${route.node.id}`;
  859. if (seen.has(key)) continue;
  860. seen.add(key);
  861. edges.push({
  862. source: site.fn.id,
  863. target: route.node.id,
  864. kind: 'calls',
  865. line: site.line,
  866. column: site.column,
  867. provenance: 'heuristic',
  868. metadata: {
  869. synthesizedBy: 'http-client',
  870. channel: 'http',
  871. callee: site.callee,
  872. tier: TIER_CLIENT_TO_SERVER,
  873. method: site.method,
  874. href: site.display,
  875. registeredAt: `${route.node.filePath}:${route.node.startLine}`,
  876. },
  877. });
  878. }
  879. await onYield();
  880. pairQueue(producers, consumers, edges, seen);
  881. pairEvents(dispatches, handlers, edges, seen);
  882. return edges;
  883. }