Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The protobuf-ts snippet uses baseUrl: "http://localhost:8080", but the new protobuf-ts example (and existing Connect-ES docs) use http://localhost:3003 for the backend. This mismatch can cause copy/paste setups to fail; consider aligning the README snippet with the example/baseUrl used elsewhere.

Suggested change
baseUrl: "http://localhost:8080",
baseUrl: "http://localhost:3003",

Copilot uses AI. Check for mistakes.
interceptors,
});
```

For a standalone snippet-only example, see [`example/protobuf-ts`](./example/protobuf-ts).
13 changes: 11 additions & 2 deletions __tests__/grpc-web.ts
Original file line number Diff line number Diff line change
@@ -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);
});
48 changes: 48 additions & 0 deletions __tests__/protobuf-ts.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Comment on lines +1 to +32

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests only assert that certain strings exist in the source file text, rather than actually testing the interceptor's behavior (e.g., that postMessageToContentScript is called with the expected arguments for unary/streaming flows, error handling, and metadata normalization). This provides no real test coverage — any refactoring that preserves behavior but changes variable names or code structure would break these tests, while bugs in the actual logic would go undetected.

The existing __tests__/grpc-web.ts test was a special case: it inspected the minified internals of a third-party library to ensure compatibility. The new interceptor is first-party code that could be tested with mocks and assertions on postMessageToContentScript calls.

Copilot uses AI. Check for mistakes.
1 change: 1 addition & 0 deletions content-scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"devDependencies": {
"@connectrpc/connect": "2.1.0",
"@protobuf-ts/runtime-rpc": "^2.11.1",

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version ranges are pinned to exact versions elsewhere in this file, but @protobuf-ts/runtime-rpc is added with a caret range (^2.11.1). This can lead to non-reproducible installs and unexpected lockfile churn; consider pinning it to an exact version (e.g., 2.11.1) to match the existing dependency style.

Suggested change
"@protobuf-ts/runtime-rpc": "^2.11.1",
"@protobuf-ts/runtime-rpc": "2.11.1",

Copilot uses AI. Check for mistakes.
"@types/chrome": "0.1.37",
"@types/google-protobuf": "3.15.12",
"grpc-web": "2.0.2",
Expand Down
6 changes: 6 additions & 0 deletions content-scripts/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
gRPCWebUnaryInterceptor,
gRPCWebUnaryInterceptorInstance,
} from "./grpc";
import { protobufTsInterceptor } from "./protobuf-ts";

declare global {
interface Window {
__gRPC_devtools__: {
gRPCWebUnaryInterceptor: gRPCWebUnaryInterceptor<Message, Message>;
gRPCWebStreamInterceptor: gRPCWebStreamInterceptor<Message, Message>;
connectEsInterceptor: Interceptor;
protobufTsInterceptor: typeof protobufTsInterceptor;
};
}
}
Expand Down Expand Up @@ -47,4 +49,8 @@ Object.defineProperties(window.__gRPC_devtools__, {
value: connectEsInterceptor,
writable: false,
},
protobufTsInterceptor: {
value: protobufTsInterceptor,
writable: false,
},
});
156 changes: 156 additions & 0 deletions content-scripts/src/main/protobuf-ts.ts
Original file line number Diff line number Diff line change
@@ -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<RpcMetadata>,
): Record<string, string> | undefined => {
if (metadata === undefined) {
return undefined;
}

return Object.entries(metadata).reduce<Record<string, string>>(
(
acc,
[
key,
value,
],
) => {
acc[key] = Array.isArray(value) ? value.map(String).join(", ") : String(value);
return acc;
},
{},
);
Comment on lines +19 to +31

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toMetadataRecord() drops all but the first value when a metadata entry is a string[] (it uses value[0]). This can silently lose header/trailer values (e.g., repeated metadata keys). Consider joining all values into a single string (e.g., comma-joined) so normalization to Record<string, string> preserves information instead of truncating it.

Copilot uses AI. Check for mistakes.
};

const mergeMetadata = (
...metadataItems: Array<undefined | Readonly<RpcMetadata>>
): Record<string, string> | undefined => {
const records = metadataItems
.map((metadata) => toMetadataRecord(metadata))
.filter((metadata): metadata is Record<string, string> => 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),
});
Comment on lines +62 to +66

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interceptor forwards call.request / finishedUnaryCall.response directly. For protobuf-ts-style message objects, this often includes internal fields like $typeName / $unknown (similar to what connect-es already strips before posting). Consider normalizing/sanitizing request/response message objects before sending them to postMessageToContentScript to keep the Devtools payload format consistent and avoid leaking internal bookkeeping fields.

Copilot uses AI. Check for mistakes.

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<string, string> | 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);

Comment on lines +101 to +121

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

responseMetadata is populated via Promise.allSettled([call.headers, call.trailers]), which will only resolve after call.trailers resolves (typically at end-of-stream). As a result, streamed onMessage events will almost always be posted with responseMetadata still undefined, so Devtools won't show response headers for the stream messages. Consider setting metadata from call.headers as soon as it resolves, and then merging in trailers on completion (or posting a separate metadata-only message when headers arrive).

Suggested change
void Promise.allSettled([
call.headers,
call.trailers,
]).then(
([
headersResult,
trailersResult,
]) => {
responseMetadata = mergeMetadata(
headersResult.status === "fulfilled" ? headersResult.value : undefined,
trailersResult.status === "fulfilled" ? trailersResult.value : undefined,
);
},
);
let responseHeaders: RpcMetadata | undefined;
// Populate metadata as soon as headers are available, then merge in trailers later.
void call.headers
.then((headers) => {
responseHeaders = headers;
responseMetadata = mergeMetadata(headers, undefined);
})
.catch(() => {
// Ignore header errors here; they will surface via onError.
});
void call.trailers
.then((trailers) => {
responseMetadata = mergeMetadata(responseHeaders, trailers);
})
.catch(() => {
// Ignore trailer errors here; they will surface via onError.
});

Copilot uses AI. Check for mistakes.
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,
});
Comment on lines +130 to +135

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streamed responses are posted with responseMessage: message without any normalization. If protobuf-ts messages include internal fields (e.g. $typeName, $unknown) like other protobuf runtimes, these will show up in Devtools and diverge from the sanitized shapes used by the Connect-ES interceptor. Consider applying the same message sanitization/normalization here before posting.

Copilot uses AI. Check for mistakes.
});

call.responses.onComplete(() => {
postMessageToContentScript({
id,
responseMetadata,
responseMessage: "EOF",
});
});

call.responses.onError((error) => {
postMessageToContentScript({
id,
responseMessage: toSerializableError(error),
errorMetadata: toMetadataRecord(error.meta),
Comment on lines +147 to +150

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onError callback in protobuf-ts's RpcOutputStream is typed as (error: Error) => void, but here error is passed to toSerializableError (which expects RpcError) and error.meta is accessed (which doesn't exist on Error). At runtime, the actual errors in gRPC calls are indeed RpcError instances so this works in practice, but the type mismatch means TypeScript won't catch misuses. The error parameter should be cast to RpcError (e.g., as RpcError) to make the type intent explicit and ensure error.code and error.meta accesses are type-safe.

Suggested change
postMessageToContentScript({
id,
responseMessage: toSerializableError(error),
errorMetadata: toMetadataRecord(error.meta),
const rpcError = error as RpcError;
postMessageToContentScript({
id,
responseMessage: toSerializableError(rpcError),
errorMetadata: toMetadataRecord(rpcError.meta),

Copilot uses AI. Check for mistakes.
});
});

return call;
},
};
42 changes: 42 additions & 0 deletions docs/issue-101-protobuf-ts-plan.md
Original file line number Diff line number Diff line change
@@ -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<string, string | string[]>` metadata to `Record<string, string>` 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.
39 changes: 39 additions & 0 deletions example/protobuf-ts/README.md
Original file line number Diff line number Diff line change
@@ -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`
14 changes: 14 additions & 0 deletions example/protobuf-ts/client/src/grpc-devtools.ts
Original file line number Diff line number Diff line change
@@ -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,
]
: [];
Loading
Loading