diff --git a/scripts/fixtures/grpc-connect/buf.gen.yaml b/scripts/fixtures/grpc-connect/buf.gen.yaml new file mode 100644 index 0000000..2479b24 --- /dev/null +++ b/scripts/fixtures/grpc-connect/buf.gen.yaml @@ -0,0 +1,4 @@ +version: v1 +plugins: + - plugin: connect-go + out: gen diff --git a/scripts/fixtures/grpc-connect/eliza.proto b/scripts/fixtures/grpc-connect/eliza.proto new file mode 100644 index 0000000..5080132 --- /dev/null +++ b/scripts/fixtures/grpc-connect/eliza.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; +package connectrpc.eliza.v1; +service ElizaService { + rpc Say(SayRequest) returns (SayResponse); +} +message SayRequest { string sentence = 1; } +message SayResponse { string sentence = 1; } diff --git a/scripts/fixtures/grpc-mixed/svc-connect/buf.gen.yaml b/scripts/fixtures/grpc-mixed/svc-connect/buf.gen.yaml new file mode 100644 index 0000000..2479b24 --- /dev/null +++ b/scripts/fixtures/grpc-mixed/svc-connect/buf.gen.yaml @@ -0,0 +1,4 @@ +version: v1 +plugins: + - plugin: connect-go + out: gen diff --git a/scripts/fixtures/grpc-mixed/svc-connect/eliza.proto b/scripts/fixtures/grpc-mixed/svc-connect/eliza.proto new file mode 100644 index 0000000..d9607b0 --- /dev/null +++ b/scripts/fixtures/grpc-mixed/svc-connect/eliza.proto @@ -0,0 +1,4 @@ +syntax="proto3"; +package eliza.v1; +service ElizaService { rpc Say(Req) returns (Resp); } +message Req{} message Resp{} diff --git a/scripts/fixtures/grpc-mixed/svc-plain/bank.proto b/scripts/fixtures/grpc-mixed/svc-plain/bank.proto new file mode 100644 index 0000000..8198ee0 --- /dev/null +++ b/scripts/fixtures/grpc-mixed/svc-plain/bank.proto @@ -0,0 +1,4 @@ +syntax="proto3"; +package bank.v1; +service BankService { rpc GetBalance(Req) returns (Resp); } +message Req{} message Resp{} diff --git a/scripts/fixtures/grpc-proto/api/v1/orders.proto b/scripts/fixtures/grpc-proto/api/v1/orders.proto new file mode 100644 index 0000000..65b4f91 --- /dev/null +++ b/scripts/fixtures/grpc-proto/api/v1/orders.proto @@ -0,0 +1,30 @@ +// Orders API — license header +/* a block comment + spanning multiple lines with a stray */ +syntax = "proto3"; +package shop.v1; + +import "google/protobuf/empty.proto"; + +service OrderService { + // Create an order + rpc CreateOrder(CreateOrderRequest) returns (Order) { + option (google.api.http) = { + post: "/v1/{parent=shops/*}/orders" + body: "*" + }; + } + rpc StreamOrders(StreamOrdersRequest) + returns (stream Order); + rpc UploadOrders(stream Order) returns (UploadSummary); + rpc Chat(stream ChatMsg) returns (stream ChatMsg); + rpc DeleteOrder(DeleteOrderRequest) returns (google.protobuf.Empty); +} + +service AdminService { + rpc Shutdown(ShutdownRequest) returns (google.protobuf.Empty); +} + +message CreateOrderRequest { string parent = 1; } +message Order { string id = 1; } +// rpc CommentedOut(Foo) returns (Bar); diff --git a/scripts/fixtures/nestjs-grpc-proto/hero.proto b/scripts/fixtures/nestjs-grpc-proto/hero.proto new file mode 100644 index 0000000..924f846 --- /dev/null +++ b/scripts/fixtures/nestjs-grpc-proto/hero.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; +package hero; +service HeroesService { + rpc FindOne(HeroById) returns (Hero); + rpc FindAll(Empty) returns (Heroes); + rpc StreamHeroes(stream HeroById) returns (stream Hero); +} +message HeroById { int32 id = 1; } +message Hero { int32 id = 1; string name = 2; } +message Empty {} +message Heroes { repeated Hero heroes = 1; } diff --git a/scripts/fixtures/nestjs-grpc-proto/package.json b/scripts/fixtures/nestjs-grpc-proto/package.json new file mode 100644 index 0000000..52e4a88 --- /dev/null +++ b/scripts/fixtures/nestjs-grpc-proto/package.json @@ -0,0 +1 @@ +{ "name": "nestjs-grpc-fixture", "dependencies": { "@nestjs/core": "^10.0.0", "@nestjs/microservices": "^10.0.0" } } diff --git a/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts b/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts new file mode 100644 index 0000000..b7ffd4c --- /dev/null +++ b/scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts @@ -0,0 +1,20 @@ +import { Controller } from '@nestjs/common'; +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; + +@Controller() +export class HeroesController { + @GrpcMethod('hero.HeroesService', 'FindOne') + findOne(data: HeroById): Hero { return {} as Hero; } + + @GrpcMethod('hero.HeroesService', 'FindAll') + findAll(data: Empty): Heroes { return {} as Heroes; } + + @GrpcStreamMethod('hero.HeroesService', 'StreamHeroes') + streamHeroes(messages: any) { return messages; } +} + +@Controller() +export class BillingController { + @GrpcMethod() + charge(data: any): any { return {}; } +} diff --git a/scripts/fixtures/nestjs-grpc/package.json b/scripts/fixtures/nestjs-grpc/package.json new file mode 100644 index 0000000..52e4a88 --- /dev/null +++ b/scripts/fixtures/nestjs-grpc/package.json @@ -0,0 +1 @@ +{ "name": "nestjs-grpc-fixture", "dependencies": { "@nestjs/core": "^10.0.0", "@nestjs/microservices": "^10.0.0" } } diff --git a/scripts/fixtures/nestjs-grpc/src/heroes.controller.ts b/scripts/fixtures/nestjs-grpc/src/heroes.controller.ts new file mode 100644 index 0000000..bf822fd --- /dev/null +++ b/scripts/fixtures/nestjs-grpc/src/heroes.controller.ts @@ -0,0 +1,14 @@ +import { Controller } from '@nestjs/common'; +import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; + +@Controller() +export class HeroesController { + @GrpcMethod('HeroesService', 'FindOne') + findOne(data: HeroById): Hero { return {} as Hero; } + + @GrpcMethod('HeroesService') + findAll(data: Empty): Heroes { return {} as Heroes; } + + @GrpcStreamMethod('HeroesService', 'StreamHeroes') + streamHeroes(messages: any) { return messages; } +} diff --git a/src/extractors/grpc.test.ts b/src/extractors/grpc.test.ts new file mode 100644 index 0000000..ea2705a --- /dev/null +++ b/src/extractors/grpc.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "path"; +import { grpc } from "./grpc.ts"; +import { createScanContext } from "../scan-context.ts"; +import { map } from "../mapper.ts"; +import { getExtractor } from "./index.ts"; + +function extract(fixture: string) { + const dir = resolve(import.meta.dir, "../../scripts/fixtures", fixture); + return grpc.extract(createScanContext(dir)); +} + +describe("grpc proto extraction", () => { + const eps = extract("grpc-proto"); + const byPath = (p: string) => eps.find((e) => e.path === p); + + test("one endpoint per rpc across all services", () => { + expect(eps.length).toBe(6); + }); + + test("wire path is /package.Service/Method", () => { + expect(byPath("/shop.v1.OrderService/CreateOrder")).toBeDefined(); + expect(byPath("/shop.v1.AdminService/Shutdown")).toBeDefined(); + }); + + test("detects all four streaming types", () => { + expect( + byPath("/shop.v1.OrderService/CreateOrder")!.grpc!.streamingType, + ).toBe("unary"); + expect( + byPath("/shop.v1.OrderService/StreamOrders")!.grpc!.streamingType, + ).toBe("server_stream"); + expect( + byPath("/shop.v1.OrderService/UploadOrders")!.grpc!.streamingType, + ).toBe("client_stream"); + expect(byPath("/shop.v1.OrderService/Chat")!.grpc!.streamingType).toBe( + "bidi", + ); + }); + + test("tags transport and framework as grpc", () => { + const e = byPath("/shop.v1.OrderService/CreateOrder")!; + expect(e.transport).toBe("grpc"); + expect(e.framework).toBe("grpc"); + expect(e.grpc!.serviceFqn).toBe("shop.v1.OrderService"); + expect(e.grpc!.method).toBe("CreateOrder"); + }); + + test("ignores commented-out rpcs", () => { + expect(byPath("/shop.v1.OrderService/CommentedOut")).toBeUndefined(); + }); + + test("survives braces and slashes inside option-string paths", () => { + // DeleteOrder is declared AFTER an `option (google.api.http)` block whose + // path literal contains `{parent=shops/*}` — the exact trap that a + // non-string-aware comment/brace scanner chokes on. + expect(byPath("/shop.v1.OrderService/DeleteOrder")).toBeDefined(); + }); +}); + +describe("grpc connect detection", () => { + const eps = extract("grpc-connect"); + test("maps to the connect transport when a connect toolchain is present", () => { + expect(eps.length).toBe(1); + expect(eps[0]!.transport).toBe("connect"); + expect(eps[0]!.framework).toBe("connect"); + expect(eps[0]!.path).toBe("/connectrpc.eliza.v1.ElizaService/Say"); + }); + + test("`--framework connect` resolves the grpc extractor", () => { + expect(getExtractor("connect")).toBe(grpc); + const dir = resolve(import.meta.dir, "../../scripts/fixtures/grpc-connect"); + const grpcEps = map(dir, { + frameworkOverride: "connect", + }).endpoints.all.filter((e) => e.grpc); + expect(grpcEps.length).toBe(1); + expect(grpcEps[0]!.transport).toBe("connect"); + }); + + test("scopes connect vs plain gRPC per package in a monorepo", () => { + const mixed = extract("grpc-mixed"); + const byPath = (p: string) => mixed.find((e) => e.path === p); + expect(byPath("/eliza.v1.ElizaService/Say")!.transport).toBe("connect"); + expect(byPath("/bank.v1.BankService/GetBalance")!.transport).toBe("grpc"); + }); +}); diff --git a/src/extractors/grpc.ts b/src/extractors/grpc.ts new file mode 100644 index 0000000..e0947f6 --- /dev/null +++ b/src/extractors/grpc.ts @@ -0,0 +1,174 @@ +import { dirname, join } from "path"; +import type { + EndpointInfo, + Extractor, + FrameworkId, + GrpcStreaming, + ScanContext, +} from "../types.ts"; +import { buildLineIndex, endpoint } from "../utils.ts"; + +const CONNECT_HINTS = [ + "connectrpc.com/connect", + "@connectrpc/", + "protoc-gen-connect", + "connect-go", + "connect-es", + "connectrpc", +]; + +const CONNECT_DEP_FILES = [ + "buf.gen.yaml", + "buf.yaml", + "package.json", + "go.mod", +]; + +// Connect vs. plain gRPC is decided per proto by walking up to the nearest +// package that declares a Connect/Buf toolchain — so a Connect service in one +// package doesn't mislabel vanilla gRPC protos elsewhere in a monorepo. Results +// are memoized per directory (ancestors are shared across sibling protos). +function makeConnectResolver(ctx: ScanContext): (file: string) => boolean { + const memo = new Map(); + const root = ctx.repoPath; + const dirUsesConnect = (dir: string): boolean => { + const cached = memo.get(dir); + if (cached !== undefined) return cached; + let result = false; + for (const name of CONNECT_DEP_FILES) { + const c = ctx.readFile(join(dir, name)); + if (c && CONNECT_HINTS.some((h) => c.toLowerCase().includes(h))) { + result = true; + break; + } + } + if (!result && dir !== root && dir.startsWith(root)) { + const parent = dirname(dir); + if (parent !== dir) result = dirUsesConnect(parent); + } + memo.set(dir, result); + return result; + }; + return (file) => dirUsesConnect(dirname(file)); +} + +// Blank out comments while preserving byte offsets and newlines. String-aware: +// proto path options carry `/*`, `*/` and `//` inside quoted literals +// (e.g. "/v1/{name=projects/*/topics/*}"), which must NOT be read as comments. +function stripComments(src: string): string { + let out = ""; + for (let i = 0; i < src.length; ) { + const c = src[i]!; + if (c === '"' || c === "'") { + out += c; + i++; + while (i < src.length && src[i] !== c) { + if (src[i] === "\\" && i + 1 < src.length) { + out += src[i]! + src[i + 1]!; + i += 2; + } else { + out += src[i]; + i++; + } + } + if (i < src.length) { + out += src[i]; + i++; + } + } else if (c === "/" && src[i + 1] === "/") { + while (i < src.length && src[i] !== "\n") { + out += " "; + i++; + } + } else if (c === "/" && src[i + 1] === "*") { + out += " "; + i += 2; + while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) { + out += src[i] === "\n" ? "\n" : " "; + i++; + } + if (i < src.length) { + out += " "; + i += 2; + } + } else { + out += c; + i++; + } + } + return out; +} + +function streaming(client: boolean, server: boolean): GrpcStreaming { + if (client && server) return "bidi"; + if (client) return "client_stream"; + if (server) return "server_stream"; + return "unary"; +} + +const PACKAGE_RE = /\bpackage\s+([A-Za-z_][\w.]*)\s*;/; +const SERVICE_RE = /\bservice\s+([A-Za-z_]\w*)\s*\{/g; +const RPC_RE = + /\brpc\s+([A-Za-z_]\w*)\s*\(\s*(stream\s+)?[.\w]+\s*\)\s*returns\s*\(\s*(stream\s+)?[.\w]+\s*\)/g; + +export const grpc: Extractor = { + id: "grpc", + detect: (_repoPath, ctx) => ctx.iterFiles([".proto"]).length > 0, + extract(ctx) { + const endpoints: EndpointInfo[] = []; + const connectForFile = makeConnectResolver(ctx); + + for (const file of ctx.iterFiles([".proto"])) { + const raw = ctx.readFile(file); + if (!raw) continue; + const framework: FrameworkId = connectForFile(file) ? "connect" : "grpc"; + const transport = framework === "connect" ? "connect" : "grpc"; + const src = stripComments(raw); + const rel = ctx.rel(file); + const lines = buildLineIndex(raw); + const pkg = PACKAGE_RE.exec(src)?.[1] ?? ""; + + // Services never nest and rpcs only live inside them, so each rpc belongs + // to the nearest service declared before it — no brace matching needed. + const services: { name: string; index: number }[] = []; + SERVICE_RE.lastIndex = 0; + let s: RegExpExecArray | null; + while ((s = SERVICE_RE.exec(src))) { + services.push({ name: s[1]!, index: s.index }); + } + if (!services.length) continue; + + RPC_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = RPC_RE.exec(src))) { + let svc: { name: string; index: number } | null = null; + for (const cand of services) { + if (cand.index < m.index) svc = cand; + else break; + } + if (!svc) continue; + + const serviceFqn = pkg ? `${pkg}.${svc.name}` : svc.name; + const method = m[1]!; + endpoints.push( + endpoint({ + method: "ANY", + path: `/${serviceFqn}/${method}`, + handler: method, + file: rel, + line: lines.lineAt(m.index), + framework, + transport, + grpc: { + serviceFqn, + method, + streamingType: streaming(!!m[2], !!m[3]), + }, + }), + ); + } + } + + return endpoints; + }, +}; diff --git a/src/extractors/index.ts b/src/extractors/index.ts index 398a0e8..eb1cd89 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -13,6 +13,7 @@ import { laravel } from "./laravel.ts"; import { sst } from "./sst.ts"; import { serverActions } from "./server-actions.ts"; import { openapi } from "./openapi.ts"; +import { grpc } from "./grpc.ts"; const ALL_EXTRACTORS: Extractor[] = [ flask, @@ -32,11 +33,15 @@ const ALL_EXTRACTORS: Extractor[] = [ sst, serverActions, openapi, + grpc, ]; const EXTRACTOR_MAP = new Map( ALL_EXTRACTORS.map((e) => [e.id, e]), ); +// The grpc extractor emits both grpc and connect transports (it picks the +// variant from the toolchain), so `--framework connect` resolves to it too. +EXTRACTOR_MAP.set("connect", grpc); export function getExtractor(id: FrameworkId): Extractor | undefined { return EXTRACTOR_MAP.get(id); diff --git a/src/extractors/nestjs.test.ts b/src/extractors/nestjs.test.ts new file mode 100644 index 0000000..e2f006c --- /dev/null +++ b/src/extractors/nestjs.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "path"; +import { nestjs } from "./nestjs.ts"; +import { createScanContext } from "../scan-context.ts"; +import { map } from "../mapper.ts"; + +function extract(fixture: string) { + const dir = resolve(import.meta.dir, "../../scripts/fixtures", fixture); + return nestjs.extract(createScanContext(dir)); +} + +describe("nestjs code-first gRPC", () => { + const eps = extract("nestjs-grpc"); + const grpc = eps.filter((e) => e.transport === "grpc"); + const byPath = (p: string) => grpc.find((e) => e.path === p); + + test("extracts @GrpcMethod handlers with explicit service + method", () => { + expect(byPath("/HeroesService/FindOne")).toBeDefined(); + }); + + test("derives the method name from the handler when the arg is omitted", () => { + // @GrpcMethod('HeroesService') on findAll() -> FindAll + expect(byPath("/HeroesService/FindAll")).toBeDefined(); + }); + + test("@GrpcStreamMethod is marked as streaming", () => { + expect(byPath("/HeroesService/StreamHeroes")!.grpc!.streamingType).toBe( + "bidi", + ); + }); + + test("keeps framework nestjs, transport grpc", () => { + const e = byPath("/HeroesService/FindOne")!; + expect(e.framework).toBe("nestjs"); + expect(e.transport).toBe("grpc"); + }); +}); + +describe("proto tiebreak + owning-class service resolution", () => { + const dir = resolve( + import.meta.dir, + "../../scripts/fixtures/nestjs-grpc-proto", + ); + const grpc = map(dir).endpoints.all.filter((e) => e.grpc); + const byPath = (p: string) => grpc.find((e) => e.path === p); + + test("on an identical wire path, the proto definition wins over the decorator", () => { + const e = byPath("/hero.HeroesService/FindOne"); + expect(e).toBeDefined(); + expect(e!.framework).toBe("grpc"); + }); + + test("a decorator with no matching proto survives", () => { + // @GrpcMethod() with no args in BillingController; resolved from its OWN + // class (BillingController -> Billing), not the file's first class. + const e = byPath("/Billing/Charge"); + expect(e).toBeDefined(); + expect(e!.framework).toBe("nestjs"); + expect(e!.grpc!.serviceFqn).toBe("Billing"); + }); +}); diff --git a/src/extractors/nestjs.ts b/src/extractors/nestjs.ts index 7e195cc..0677d39 100644 --- a/src/extractors/nestjs.ts +++ b/src/extractors/nestjs.ts @@ -75,6 +75,56 @@ export const nestjs: Extractor = { } } + // gRPC handlers (@GrpcMethod / @GrpcStreamMethod). Emitted unconditionally; + // when a .proto also defines the method, the mapper prefers the + // package-qualified proto endpoint over this bare decorator one. + const grpcRe = + /@(GrpcMethod|GrpcStreamMethod)\s*\(([^)]*)\)[\s\S]{0,160}?(?:async\s+)?(\w+)\s*\(/g; + for (const f of tsFiles) { + const content = ctx.readFile(f); + if (!content || !content.includes("@Grpc")) continue; + const rel = ctx.rel(f); + const lines = buildLineIndex(content); + // Associate each decorator with the class that owns it (nearest `class` + // declared before it), so files with multiple controllers resolve the + // fallback service name from the right class. + const classes = [...content.matchAll(/\bclass\s+(\w+)/g)].map((c) => ({ + name: c[1]!, + index: c.index, + })); + const classAt = (offset: number) => { + let name = ""; + for (const c of classes) { + if (c.index < offset) name = c.name; + else break; + } + return name; + }; + for (const m of content.matchAll(grpcRe)) { + const args = [...m[2]!.matchAll(/['"]([^'"]+)['"]/g)].map((a) => a[1]!); + const handler = m[3]!; + const service = args[0] ?? classAt(m.index).replace(/Controller$/, ""); + const method = + args[1] ?? handler.charAt(0).toUpperCase() + handler.slice(1); + endpoints.push( + endpoint({ + method: "ANY", + path: `/${service}/${method}`, + handler, + file: rel, + line: lines.lineAt(m.index), + framework: "nestjs", + transport: "grpc", + grpc: { + serviceFqn: service, + method, + streamingType: m[1] === "GrpcStreamMethod" ? "bidi" : "unary", + }, + }), + ); + } + } + return endpoints; }, }; diff --git a/src/format.ts b/src/format.ts index 2d3cdb8..ad4134d 100644 --- a/src/format.ts +++ b/src/format.ts @@ -62,7 +62,11 @@ function renderEndpointRows( ): string[] { const lines: string[] = []; for (const ep of endpoints) { - const badge = methodBadge(ep.method); + const badge = methodBadge( + ep.transport && ep.transport !== "http" + ? ep.transport.toUpperCase() + : ep.method, + ); const path = ep.path.length > pathW ? ep.path.slice(0, pathW - 1) + "…" @@ -220,6 +224,8 @@ export function formatJson(result: MapResult, options?: FormatOptions): string { framework: e.framework, }; if (e.handlerFile) obj.handlerFile = e.handlerFile; + if (e.transport) obj.transport = e.transport; + if (e.grpc) obj.grpc = e.grpc; if (e.service) obj.service = e.service; if (e.params.length) obj.params = e.params; if (e.auth.length) obj.auth = e.auth; @@ -272,6 +278,8 @@ export function formatNdjson( framework: e.framework, }; if (e.handlerFile) obj.handlerFile = e.handlerFile; + if (e.transport) obj.transport = e.transport; + if (e.grpc) obj.grpc = e.grpc; if (e.service) obj.service = e.service; if (e.params.length) obj.params = e.params; if (e.auth.length) obj.auth = e.auth; @@ -331,8 +339,12 @@ export function formatMarkdown( for (const ep of svcEndpoints) { const auth = ep.auth.join(", "); + const methodLabel = + ep.transport && ep.transport !== "http" + ? ep.transport.toUpperCase() + : ep.method; lines.push( - `| ${ep.method} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, + `| ${methodLabel} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, ); } } @@ -344,8 +356,12 @@ export function formatMarkdown( for (const ep of fwEndpoints) { const auth = ep.auth.join(", "); + const methodLabel = + ep.transport && ep.transport !== "http" + ? ep.transport.toUpperCase() + : ep.method; lines.push( - `| ${ep.method} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, + `| ${methodLabel} | \`${ep.path}\` | ${ep.handler} | ${ep.file}:${ep.line} | ${auth} |`, ); } } diff --git a/src/index.ts b/src/index.ts index 52779a7..fadd16b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,8 +9,11 @@ export type { DiffHunk, EndpointInfo, EndpointKind, + EndpointTransport, FunctionDef, FrameworkId, + GrpcMeta, + GrpcStreaming, HttpMethod, ImpactResult, MapResult, diff --git a/src/mapper.ts b/src/mapper.ts index 3c1f46c..67eb3b9 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -385,16 +385,27 @@ export function mapRaw( // Main mapper — dedup, filter, sort over raw results // --------------------------------------------------------------------------- +// A `.proto` and a framework decorator (e.g. NestJS `@GrpcMethod`) can describe +// the same gRPC method. We only collapse them when they resolve to the SAME +// wire path (identical `transport::method::path`) — matching on the unqualified +// service name would wrongly merge unrelated services that share a short name. +// On a genuine collision the proto definition wins (it's the canonical source). +const isProtoGrpc = (ep: EndpointInfo) => + ep.framework === "grpc" || ep.framework === "connect"; + export function map(repoPath: string, options: MapOptions = {}): MapResult { const raw = mapRaw(repoPath, options); - const seen = new Set(); + const indexByKey = new Map(); const unique: EndpointInfo[] = []; for (const ep of raw.endpoints) { - const key = `${ep.method}::${ep.path}`; - if (!seen.has(key)) { - seen.add(key); + const key = `${ep.transport ?? "http"}::${ep.method}::${ep.path}`; + const idx = indexByKey.get(key); + if (idx === undefined) { + indexByKey.set(key, unique.length); unique.push(ep); + } else if (isProtoGrpc(ep) && !isProtoGrpc(unique[idx]!)) { + unique[idx] = ep; } } diff --git a/src/types.ts b/src/types.ts index b992df0..9f17ba3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,20 @@ export type HttpMethod = export type EndpointKind = "api" | "page" | "action" | "websocket"; +export type EndpointTransport = "http" | "grpc" | "grpc_web" | "connect"; + +export type GrpcStreaming = + | "unary" + | "server_stream" + | "client_stream" + | "bidi"; + +export interface GrpcMeta { + serviceFqn: string; + method: string; + streamingType: GrpcStreaming; +} + export type ServiceType = | "nextjs" | "lambda" @@ -48,6 +62,10 @@ export interface EndpointInfo { params: ParamInfo[]; auth: string[]; internal: boolean; + /** Wire transport. Absent means plain HTTP. */ + transport?: EndpointTransport; + /** Present when transport is a gRPC variant. */ + grpc?: GrpcMeta; service?: string; /** Repo-relative path of the handler's source file, when distinct from `file` * (e.g. SST routes declared in infra/ but implemented in packages/functions/). */ @@ -91,7 +109,9 @@ export type FrameworkId = | "laravel" | "sst" | "server_actions" - | "openapi"; + | "openapi" + | "grpc" + | "connect"; export interface FrameworkDetect { /** Substring matches checked against dependency file content */ diff --git a/src/utils.ts b/src/utils.ts index 39d82c0..6276d5b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,9 @@ import type { EndpointInfo, EndpointKind, + EndpointTransport, FrameworkId, + GrpcMeta, HttpMethod, ParamInfo, } from "./types.ts"; @@ -104,6 +106,8 @@ export function endpoint(e: { kind?: EndpointKind; params?: ParamInfo[]; auth?: string[]; + transport?: EndpointTransport; + grpc?: GrpcMeta; service?: string; handlerFile?: string; serviceRoot?: string; @@ -120,6 +124,8 @@ export function endpoint(e: { auth: e.auth ?? [], internal: false, }; + if (e.transport) ep.transport = e.transport; + if (e.grpc) ep.grpc = e.grpc; if (e.service) ep.service = e.service; if (e.handlerFile) ep.handlerFile = e.handlerFile; if (e.serviceRoot) ep.serviceRoot = e.serviceRoot;