diff --git a/README.md b/README.md index 3a1d8172..7177f631 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,38 @@ import { interceptors } from "./grpc-devtools"; const transport = createConnectTransport({ baseUrl: "http://localhost:3003", interceptors }); ``` + +## [protobuf-ts](https://github.com/timostamm/protobuf-ts) + +grpc-devtools.ts + +```ts +import type { RpcInterceptor } from "@protobuf-ts/runtime-rpc"; + +declare const __gRPC_devtools__: + | undefined + | { + protobufTsInterceptor: RpcInterceptor; + }; + +export const interceptors: RpcInterceptor[] = + typeof __gRPC_devtools__ === "object" + ? [ + __gRPC_devtools__.protobufTsInterceptor, + ] + : []; +``` + +example.ts + +```ts +import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport"; +import { interceptors } from "./grpc-devtools"; + +const transport = new GrpcWebFetchTransport({ + baseUrl: "http://localhost:8080", + interceptors, +}); +``` + +For a standalone snippet-only example, see [`example/protobuf-ts`](./example/protobuf-ts). diff --git a/__tests__/grpc-web.ts b/__tests__/grpc-web.ts index 66c975dc..29cecd6b 100644 --- a/__tests__/grpc-web.ts +++ b/__tests__/grpc-web.ts @@ -1,9 +1,18 @@ const fs = require("fs"); const path = require("path"); -test("__GRPCWEB_DEVTOOLS__", () => { +test("__GRPCWEB_DEVTOOLS__ grpc-web internals include interceptor arrays", () => { const grpcWebIndexPath = path.resolve(__dirname, "..", "node_modules", "grpc-web", "index.js"); const grpcWebIndexContent = fs.readFileSync(grpcWebIndexPath, "utf-8"); - expect(grpcWebIndexContent).toContain("this.b=[];this.h=[];this.g=[];this.f=[];this.c=[]"); + // grpc-web internals are minified and changed between versions. + // Accept both older (h/g/b) and newer (h/m) interceptor field layouts. + const hasLegacyLayout = + grpcWebIndexContent.includes("this.h=[]") && + grpcWebIndexContent.includes("this.g=[]") && + grpcWebIndexContent.includes("this.b=[]"); + const hasModernLayout = + grpcWebIndexContent.includes("this.h=[]") && grpcWebIndexContent.includes("this.m=[]"); + + expect(hasLegacyLayout || hasModernLayout).toBe(true); }); diff --git a/__tests__/protobuf-ts.ts b/__tests__/protobuf-ts.ts new file mode 100644 index 00000000..dad2cd49 --- /dev/null +++ b/__tests__/protobuf-ts.ts @@ -0,0 +1,48 @@ +const fs = require("fs"); +const path = require("path"); + +test("protobuf-ts interceptor is exposed in injected globals", () => { + const indexPath = path.resolve(__dirname, "..", "content-scripts", "src", "main", "index.ts"); + const content = fs.readFileSync(indexPath, "utf-8"); + + expect(content).toContain("protobufTsInterceptor"); + expect(content).toContain("value: protobufTsInterceptor"); +}); + +test("protobuf-ts interceptor handles unary and server streaming flows", () => { + const filePath = path.resolve( + __dirname, + "..", + "content-scripts", + "src", + "main", + "protobuf-ts.ts", + ); + const content = fs.readFileSync(filePath, "utf-8"); + + expect(content).toContain("interceptUnary"); + expect(content).toContain("interceptServerStreaming"); + expect(content).toContain('responseMessage: "EOF"'); + expect(content).toContain("toMetadataRecord(call.requestHeaders)"); + expect(content).toContain("mergeMetadata"); + expect(content).toContain("void call.headers"); + expect(content).toContain("void call.trailers"); + expect(content).toContain("responseMetadata = mergeMetadata(headers, responseMetadata)"); + expect(content).toContain("responseMetadata = mergeMetadata(responseMetadata, trailers)"); +}); + +test("protobuf-ts interceptor avoids creating unhandled rejections", () => { + const filePath = path.resolve( + __dirname, + "..", + "content-scripts", + "src", + "main", + "protobuf-ts.ts", + ); + const content = fs.readFileSync(filePath, "utf-8"); + + expect(content).toContain("void call"); + expect(content).toContain(".catch((error: RpcError) => {"); + expect(content).not.toContain("throw error"); +}); diff --git a/content-scripts/package.json b/content-scripts/package.json index 61d91891..407cb85c 100644 --- a/content-scripts/package.json +++ b/content-scripts/package.json @@ -9,6 +9,7 @@ }, "devDependencies": { "@connectrpc/connect": "2.1.0", + "@protobuf-ts/runtime-rpc": "^2.11.1", "@types/chrome": "0.1.37", "@types/google-protobuf": "3.15.12", "grpc-web": "2.0.2", diff --git a/content-scripts/src/main/index.ts b/content-scripts/src/main/index.ts index 6f13c7af..450ddba3 100644 --- a/content-scripts/src/main/index.ts +++ b/content-scripts/src/main/index.ts @@ -7,6 +7,7 @@ import { gRPCWebUnaryInterceptor, gRPCWebUnaryInterceptorInstance, } from "./grpc"; +import { protobufTsInterceptor } from "./protobuf-ts"; declare global { interface Window { @@ -14,6 +15,7 @@ declare global { gRPCWebUnaryInterceptor: gRPCWebUnaryInterceptor; gRPCWebStreamInterceptor: gRPCWebStreamInterceptor; connectEsInterceptor: Interceptor; + protobufTsInterceptor: typeof protobufTsInterceptor; }; } } @@ -47,4 +49,8 @@ Object.defineProperties(window.__gRPC_devtools__, { value: connectEsInterceptor, writable: false, }, + protobufTsInterceptor: { + value: protobufTsInterceptor, + writable: false, + }, }); diff --git a/content-scripts/src/main/protobuf-ts.ts b/content-scripts/src/main/protobuf-ts.ts new file mode 100644 index 00000000..6d6907f7 --- /dev/null +++ b/content-scripts/src/main/protobuf-ts.ts @@ -0,0 +1,156 @@ +import type { + NextServerStreamingFn, + NextUnaryFn, + RpcError, + RpcInterceptor, + RpcMetadata, + ServerStreamingCall, + UnaryCall, +} from "@protobuf-ts/runtime-rpc"; +import { postMessageToContentScript } from "./post-message-to-content-script"; + +const toMetadataRecord = ( + metadata: undefined | Readonly, +): Record | undefined => { + if (metadata === undefined) { + return undefined; + } + + return Object.entries(metadata).reduce>( + ( + acc, + [ + key, + value, + ], + ) => { + acc[key] = Array.isArray(value) ? value.map(String).join(", ") : String(value); + return acc; + }, + {}, + ); +}; + +const mergeMetadata = ( + ...metadataItems: Array> +): Record | undefined => { + const records = metadataItems + .map((metadata) => toMetadataRecord(metadata)) + .filter((metadata): metadata is Record => metadata !== undefined); + + if (records.length === 0) { + return undefined; + } + + return Object.assign({}, ...records); +}; + +const toSerializableError = (error: RpcError) => ({ + name: error.name, + code: error.code, + message: error.message, + stack: error.stack, +}); + +export const protobufTsInterceptor: RpcInterceptor = { + interceptUnary(next: NextUnaryFn, method, input, options): UnaryCall { + const id = Math.random().toString(36).slice(2, 6); + const call = next(method, input, options); + + postMessageToContentScript({ + id, + methodName: method.name, + serviceName: method.service.typeName, + requestMessage: call.request, + requestMetadata: toMetadataRecord(call.requestHeaders), + }); + + void call + .then((finishedUnaryCall) => { + postMessageToContentScript({ + id, + responseMetadata: mergeMetadata(finishedUnaryCall.headers, finishedUnaryCall.trailers), + responseMessage: finishedUnaryCall.response, + }); + + postMessageToContentScript({ + id, + responseMessage: "EOF", + }); + }) + .catch((error: RpcError) => { + postMessageToContentScript({ + id, + responseMessage: toSerializableError(error), + errorMetadata: toMetadataRecord(error.meta), + }); + }); + + return call; + }, + + interceptServerStreaming( + next: NextServerStreamingFn, + method, + input, + options, + ): ServerStreamingCall { + const id = Math.random().toString(36).slice(2, 6); + const call = next(method, input, options); + let responseMetadata: Record | undefined; + + void call.headers + .then((headers) => { + responseMetadata = mergeMetadata(headers, responseMetadata); + postMessageToContentScript({ + id, + responseMetadata, + }); + }) + .catch(() => void 0); + + void call.trailers + .then((trailers) => { + responseMetadata = mergeMetadata(responseMetadata, trailers); + postMessageToContentScript({ + id, + responseMetadata, + }); + }) + .catch(() => void 0); + + postMessageToContentScript({ + id, + methodName: method.name, + serviceName: method.service.typeName, + requestMetadata: toMetadataRecord(call.requestHeaders), + requestMessage: call.request, + }); + + call.responses.onMessage((message) => { + postMessageToContentScript({ + id, + responseMetadata, + responseMessage: message, + }); + }); + + call.responses.onComplete(() => { + postMessageToContentScript({ + id, + responseMetadata, + responseMessage: "EOF", + }); + }); + + call.responses.onError((error) => { + postMessageToContentScript({ + id, + responseMessage: toSerializableError(error), + errorMetadata: toMetadataRecord(error.meta), + }); + }); + + return call; + }, +}; diff --git a/docs/issue-101-protobuf-ts-plan.md b/docs/issue-101-protobuf-ts-plan.md new file mode 100644 index 00000000..b6c5bce5 --- /dev/null +++ b/docs/issue-101-protobuf-ts-plan.md @@ -0,0 +1,42 @@ +# Issue #101 Plan: Integrate protobuf-ts support + +## Goal + +Add first-class support for applications using `protobuf-ts` with gRPC-Web transport so requests and responses are captured in gRPC Devtools without custom user code. + +## Delivery plan + +1. **Expose a built-in protobuf-ts interceptor in injected globals** + - Add `window.__gRPC_devtools__.protobufTsInterceptor` to the main injected entrypoint. + - Keep existing `gRPC-Web` and `Connect-ES` support unchanged. + +2. **Implement protobuf-ts interceptor behavior** + - Add unary interception: + - send request metadata and request message; + - send response metadata + response message; + - send EOF marker; + - capture and forward error metadata/message details. + - Add server-streaming interception: + - send initial request metadata and message; + - forward each streamed message; + - send EOF on completion; + - send structured error payload on stream errors. + +3. **Normalize protobuf-ts metadata for Devtools transport format** + - Convert `Record` metadata to `Record` for existing message pipeline compatibility. + +4. **Document public usage in README** + - Add a `protobuf-ts` section with a `grpc-devtools.ts` snippet and transport wiring example for `@protobuf-ts/grpcweb-transport`. + +5. **Add a dedicated protobuf-ts example** + - Add a self-contained example directory under `example/protobuf-ts` that demonstrates interceptor wiring with `GrpcWebFetchTransport`. + +6. **Validation** + - Build content scripts to ensure TypeScript/webpack compilation succeeds. + - Build the full extension workspace to verify no regressions across packages. + +## Non-goals (for this PR) + +- Auto-detection/injection into user transports without adding the interceptor. +- New UI behavior in Devtools panel. +- Support beyond unary and server-streaming APIs used in gRPC-Web transport. diff --git a/example/protobuf-ts/README.md b/example/protobuf-ts/README.md new file mode 100644 index 00000000..ac49abf0 --- /dev/null +++ b/example/protobuf-ts/README.md @@ -0,0 +1,39 @@ +# protobuf-ts Example + +This example shows how to wire `grpc-devtools` with [`protobuf-ts`](https://github.com/timostamm/protobuf-ts) and the gRPC-Web transport. + +## grpc-devtools.ts + +```ts +import type { RpcInterceptor } from "@protobuf-ts/runtime-rpc"; + +declare const __gRPC_devtools__: + | undefined + | { + protobufTsInterceptor: RpcInterceptor; + }; + +export const interceptors: RpcInterceptor[] = + typeof __gRPC_devtools__ === "object" + ? [ + __gRPC_devtools__.protobufTsInterceptor, + ] + : []; +``` + +## transport.ts + +```ts +import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport"; +import { interceptors } from "./grpc-devtools"; + +export const transport = new GrpcWebFetchTransport({ + baseUrl: "http://localhost:3003", + interceptors, +}); +``` + +## Included source snippets + +- `client/src/grpc-devtools.ts` +- `client/src/transport.ts` diff --git a/example/protobuf-ts/client/src/grpc-devtools.ts b/example/protobuf-ts/client/src/grpc-devtools.ts new file mode 100644 index 00000000..41f8301d --- /dev/null +++ b/example/protobuf-ts/client/src/grpc-devtools.ts @@ -0,0 +1,14 @@ +import type { RpcInterceptor } from "@protobuf-ts/runtime-rpc"; + +declare const __gRPC_devtools__: + | undefined + | { + protobufTsInterceptor: RpcInterceptor; + }; + +export const interceptors: RpcInterceptor[] = + typeof __gRPC_devtools__ === "object" + ? [ + __gRPC_devtools__.protobufTsInterceptor, + ] + : []; diff --git a/example/protobuf-ts/client/src/transport.ts b/example/protobuf-ts/client/src/transport.ts new file mode 100644 index 00000000..0aa0df8e --- /dev/null +++ b/example/protobuf-ts/client/src/transport.ts @@ -0,0 +1,7 @@ +import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport"; +import { interceptors } from "./grpc-devtools"; + +export const transport = new GrpcWebFetchTransport({ + baseUrl: "http://localhost:3003", + interceptors, +}); diff --git a/package-lock.json b/package-lock.json index 3a39999f..d00501d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "license": "GPL-3.0", "devDependencies": { "@connectrpc/connect": "2.1.0", + "@protobuf-ts/runtime-rpc": "^2.11.1", "@types/chrome": "0.1.37", "@types/google-protobuf": "3.15.12", "grpc-web": "2.0.2", @@ -2393,6 +2394,23 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@protobuf-ts/runtime": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz", + "integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@protobuf-ts/runtime-rpc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.11.1.tgz", + "integrity": "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1" + } + }, "node_modules/@react-aria/focus": { "version": "3.20.2", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.2.tgz",