# @massiv-oss/grpc-fake-server

> A fake gRPC server that returns responses from Protobuf schemas, with a buf plugin that generates typed admin clients for tests.

Latest version **1.2.0** (published 2026-10-04) · MIT license · 0 weekly downloads

## Install

```sh
npm install @massiv-oss/grpc-fake-server
pnpm add @massiv-oss/grpc-fake-server
yarn add @massiv-oss/grpc-fake-server
bun add @massiv-oss/grpc-fake-server
```

Provides the commands `grpc-fake-server`, `protoc-gen-grpc-fake`.

## Health

**Score 60/100 (C)** — status: active.

Positive: esm support; no vulnerabilities; recently updated; high maintenance score.

Warnings: low downloads; no types.

## Facts

| | |
|---|---|
| Version | 1.2.0 |
| Published | 2026-10-04 |
| First published | 2026-10-03 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | none |
| Module format | ESM |
| Node | >=24.0.0 |
| Dependencies | 6 |
| Unpacked size | 423.7 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Maintainers | massiv_agent, azu |
| Keywords | buf, connect, fake, grpc, grpc-web, mock, protobuf, testing |

## Links

- npm: https://www.npmjs.com/package/@massiv-oss/grpc-fake-server
- Repository: https://github.com/massiv-oss/grpc-fake-server
- Homepage: https://github.com/massiv-oss/grpc-fake-server/tree/main/packages/grpc-fake-server#readme
- Issues: https://github.com/massiv-oss/grpc-fake-server/issues
- npm.io page: https://npm.io/package/@massiv-oss/grpc-fake-server

## Dependencies (6)

- [@bufbuild/buf](https://npm.io/package/@bufbuild/buf.md) ^1.71.0
- [@bufbuild/cel](https://npm.io/package/@bufbuild/cel.md) ^0.6.1
- [@bufbuild/protobuf](https://npm.io/package/@bufbuild/protobuf.md) ^2.15.0
- [@connectrpc/connect](https://npm.io/package/@connectrpc/connect.md) ^2.1.2
- [@bufbuild/protoplugin](https://npm.io/package/@bufbuild/protoplugin.md) ^2.15.0
- [@connectrpc/connect-node](https://npm.io/package/@connectrpc/connect-node.md) ^2.1.2

## Alternatives

- [pagerjs](https://npm.io/package/pagerjs.md) — 60 weekly downloads
- [whistle.savefor-mock](https://npm.io/package/whistle.savefor-mock.md) — 4 weekly downloads
- [@anil-labs/factory](https://npm.io/package/@anil-labs/factory.md) — 0 weekly downloads
- [@buoy-gg/location](https://npm.io/package/@buoy-gg/location.md) — 0 weekly downloads
- [ng-postcode](https://npm.io/package/ng-postcode.md) — 0 weekly downloads

## Recent versions

- 1.2.0 (latest) — 2026-10-04
- 1.1.0 — 2026-10-04
- 1.0.0 — 2026-10-03
- 0.0.0 — 2026-10-03

## README

# grpc-fake-server

A fake gRPC server that returns responses from Protobuf schemas without a real backend.

- Serves gRPC, gRPC-Web, and Connect on one port.
- Builds default responses from example values declared as Protobuf options (Declarative Fake).
- Lets tests register whole responses or RPC errors per RPC and read the call history (Dynamic Fake), isolated per test by a sequence ID.
- A buf plugin generates a typed admin client for each service.
- Reloads automatically when the Protobuf files change.

> [!NOTE]
> This project is under construction and has not been released yet.
> The options use the extension numbers 79133–79135 from the range that Protobuf leaves for use within an organization (50000–99999). If your own extensions of `google.protobuf.FieldOptions` or `google.protobuf.OneofOptions` use these numbers, `buf build` fails with a conflict.

The design and the reasoning behind it are in [docs/design.md](https://github.com/massiv-oss/grpc-fake-server/blob/main/docs/design.md).

## Quick start

1. Install the package, and add its Protobuf directory to `buf.yaml` ([Declare example values](#declare-example-values)).
2. Declare example values on the fields of your response messages.
3. Start the server: `npx grpc-fake-server . --service example.v1.BookService` ([Start the server](#start-the-server)).
4. Generate the admin client with `protoc-gen-grpc-fake` ([Generate the admin client](#generate-the-admin-client)).
5. In each test, register the responses it needs with the admin client ([Use the admin client](#use-the-admin-client)).

## Install

```sh
npm install --save-dev @massiv-oss/grpc-fake-server @bufbuild/protoc-gen-es
npm install @bufbuild/protobuf
```

Node.js 24 or later is required. The server runs `buf build` with its own dependency on [`@bufbuild/buf`](https://buf.build/docs/cli/installation/), so the server does not need the Buf CLI on `PATH`. To run `buf generate` yourself with a package manager that does not hoist dependencies, such as pnpm, also add `@bufbuild/buf` to your devDependencies.

## Declare example values

The package ships the option definitions in `proto/grpc_fake/v1/options.proto`. Add the directory to your `buf.yaml` as a module so that your Protobuf files can import it.

```yaml
version: v2
modules:
  - path: proto
  - path: node_modules/@massiv-oss/grpc-fake-server/proto
```

Import the options with `import option` (edition 2024). An option import is not a dependency of the generated code, so you do not need to generate code for the options. With an older edition or `proto3`, use a regular `import "grpc_fake/v1/options.proto";` instead; the options then become a dependency of the generated code.

```proto
edition = "2024";

package example.v1;

import option "grpc_fake/v1/options.proto";

message Book {
  string title = 1 [(grpc_fake.v1.example).string = "The Pragmatic Programmer"];
}
```

A field without a declaration gets a fixed default value of its type (see [Default values](#default-values)), so you only declare the fields whose values matter for your screens and tests. The server checks every declaration against the field type at startup, and the plugin checks them at generation. A wrong declaration fails with the Protobuf file and the field name in the error.

The examples below cover every kind of value. [examples/vitest](https://github.com/massiv-oss/grpc-fake-server/blob/main/examples/vitest/proto/example/v1/book_service.proto) has all of them in one file, with tests that read them through the server.

### Scalars

Each scalar type has its own option, named after the type. An `int32` option on an `int64` field is a type mismatch.

```proto
message Book {
  string title = 1 [(grpc_fake.v1.example).string = "The Pragmatic Programmer"];
  int32 pages = 2 [(grpc_fake.v1.example).int32 = 352];
  int64 sold = 3 [(grpc_fake.v1.example).int64 = 9007199254740993];
  uint32 edition = 4 [(grpc_fake.v1.example).uint32 = 2];
  sint64 balance = 5 [(grpc_fake.v1.example).sint64 = -100];
  fixed64 checksum = 6 [(grpc_fake.v1.example).fixed64 = 12345];
  float rating = 7 [(grpc_fake.v1.example).float = 4.5];
  double price = 8 [(grpc_fake.v1.example).double = 39.99];
  bool in_stock = 9 [(grpc_fake.v1.example).bool = true];
  bytes cover = 10 [(grpc_fake.v1.example).bytes = "\x89PNG"];
}
```

### Enums

Give the name of a value defined in the enum. The name is checked against the enum.

```proto
enum Genre {
  GENRE_UNSPECIFIED = 0;
  GENRE_FICTION = 1;
  GENRE_TECHNOLOGY = 2;
}

message Book {
  Genre genre = 1 [(grpc_fake.v1.example).enum = "GENRE_TECHNOLOGY"];
}
```

### Repeated fields: a fixed list

`list` declares the whole list. Each element is an example value of the element type. `list = {}` declares an empty list explicitly.

```proto
message Book {
  repeated string tags = 1 [(grpc_fake.v1.example).list = {
    values: [
      {string: "software"},
      {string: "career"}
    ]
  }];
  repeated int32 chapters = 2 [(grpc_fake.v1.example).list = {
    values: [{int32: 1}, {int32: 2}, {int32: 3}]
  }];
  repeated string errata = 3 [(grpc_fake.v1.example).list = {}];
}
```

### Repeated messages: `count` and `uuid`

For a list of messages, declare example values on the fields of the element type, and set the number of elements with `count`. Use `uuid` for identifiers: each element gets a different UUID, built from the seed and the path of the field (such as `books[1].id`). The first 8 digits are `00000000`, so values from the fake are easy to recognize, and the same declaration at the same position always gives the same value.

```proto
message ListBooksResponse {
  // Three books. Each one is built from the declarations of Book.
  repeated Book books = 1 [(grpc_fake.v1.count) = 3];
}

message Book {
  // books[0].id, books[1].id, and books[2].id get different UUIDs.
  string id = 1 [(grpc_fake.v1.example).uuid = "book"];
  string title = 2 [(grpc_fake.v1.example).string = "The Pragmatic Programmer"];
  // Nested counts work too. The UUIDs include both indexes, such as books[1].reviews[0].id.
  repeated Review reviews = 3 [(grpc_fake.v1.count) = 2];
}

message Review {
  string id = 1 [(grpc_fake.v1.example).uuid = "review"];
}
```

`count` cannot be combined with `list` on the same field.

### Maps

`map` declares all entries. Each key and value is an example value of the key and value types.

```proto
message Book {
  map<string, int32> ratings = 1 [(grpc_fake.v1.example).map = {
    entries: [
      {
        key: {string: "goodreads"}
        value: {int32: 4}
      },
      {
        key: {string: "amazon"}
        value: {int32: 5}
      }
    ]
  }];
}
```

### Messages

A message field without a declaration is built recursively from the declarations of its type. `message` sets the whole value of one field instead: the listed fields are set and the other fields are left unset. Use it when one field needs a different value from the type's declarations.

```proto
message Book {
  // Built from the declarations of Author: {name: "Anonymous", bio: "string"}.
  Author author = 1;
  // Only name is set; bio is unset.
  Author editor = 2 [(grpc_fake.v1.example).message = {
    fields: [
      {
        name: "name"
        value: {string: "Andy Hunt"}
      }
    ]
  }];
}

message Author {
  string name = 1 [(grpc_fake.v1.example).string = "Anonymous"];
  string bio = 2;
}
```

A message field that refers back to its own type (such as `User invited_by` in `User`) is left unset, so recursive types do not loop.

### Oneofs

A oneof is left unset unless it declares which member to select, like the Protobuf default. To select a member, put `oneof_example` on the oneof. The value is an example of the message that has the oneof, written with its type name, that sets exactly one member. The Protobuf compiler checks the field names and value types.

```proto
message Book {
  oneof format {
    option (grpc_fake.v1.oneof_example) = {[type.googleapis.com/example.v1.Book]: {ebook: {file_size_bytes: 2048000}}};

    Paperback paperback = 1;
    Ebook ebook = 2;
  }

  oneof price {
    option (grpc_fake.v1.oneof_example) = {[type.googleapis.com/example.v1.Book]: {free: true}};

    double amount = 3;
    bool free = 4;
  }
}
```

`example` on a oneof member is an error: declare the selected member on the oneof instead.

### Well-known types

Timestamp, Duration, Empty, Struct, Value, ListValue, and FieldMask take the well-known message itself, written in the Protobuf text format. Wrapper types take the wrapped scalar with `wrapper`.

```proto
import "google/protobuf/duration.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
import "google/protobuf/struct.proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";

message Book {
  // 2019-09-16T00:00:00Z
  google.protobuf.Timestamp published_at = 1 [(grpc_fake.v1.example).timestamp = {seconds: 1568592000}];
  // 10 hours
  google.protobuf.Duration reading_time = 2 [(grpc_fake.v1.example).duration = {seconds: 36000}];
  google.protobuf.StringValue subtitle = 3 [(grpc_fake.v1.example).wrapper = {string: "Your Journey to Mastery"}];
  google.protobuf.Int32Value stock = 4 [(grpc_fake.v1.example).wrapper = {int32: 12}];
  google.protobuf.Empty marker = 5 [(grpc_fake.v1.example).empty = {}];
  google.protobuf.FieldMask mask = 6 [(grpc_fake.v1.example).field_mask = {paths: ["title", "price"]}];
  google.protobuf.Struct metadata = 7 [(grpc_fake.v1.example).struct = {
    fields: {
      key: "source"
      value: {string_value: "catalog"}
    }
  }];
  google.protobuf.Value extra = 8 [(grpc_fake.v1.example).value = {number_value: 1}];
  google.protobuf.ListValue history = 9 [(grpc_fake.v1.example).list_value = {
    values: [{string_value: "draft"}, {string_value: "published"}]
  }];
}
```

### Any

`any` takes the type URL and the fields of the contained message. The type must be one that the server has loaded.

```proto
import "google/protobuf/any.proto";

message Book {
  google.protobuf.Any detail = 1 [(grpc_fake.v1.example).any = {
    type_url: "type.googleapis.com/example.v1.Ebook"
    value: {
      fields: [
        {
          name: "file_size_bytes"
          value: {int64: 2048000}
        }
      ]
    }
  }];
}
```

### Default values

When a field has no declaration, the server uses these values. Random numbers and the current time are never used, so the same schema always gives the same response.

| Type                                | Default                                                                  |
| ----------------------------------- | ------------------------------------------------------------------------ |
| string                              | `"string"`                                                               |
| numbers (int32, int64, double, ...) | `42`                                                                     |
| bool                                | `true`                                                                   |
| bytes                               | empty                                                                    |
| enum                                | the first value in the definition                                        |
| repeated, map                       | empty                                                                    |
| oneof                               | unset                                                                    |
| message                             | built recursively; unset on a cycle back to the same type or at depth 16 |
| Timestamp                           | `2026-09-27T01:23:45Z`                                                   |
| Duration                            | 0 seconds                                                                |
| wrappers                            | the default of the wrapped scalar                                        |
| Empty, Struct, ListValue, FieldMask | empty                                                                    |
| Value                               | `null`                                                                   |
| Any                                 | unset                                                                    |

Declarations on a message type apply to every RPC that uses the type. For a different value in one RPC or one test, register a response with the admin client.

## Start the server

Pass a Buf input and the full names (including the Protobuf package) of the services to serve. You do not need to know where the Protobuf files are.

```sh
npx grpc-fake-server . \
  --service example.v1.UserService \
  --allow-origin http://localhost:3000
```

The server runs `buf build` on the whole input with the bundled Buf CLI, then finds the services by name. The input can be any [Buf input](https://buf.build/docs/reference/inputs/): a directory with `buf.yaml`, a descriptor set, a Git repository, a Buf Schema Registry module, and so on. Services in imported files and dependency modules can also be served by their full names.

Protobuf files without declarations work too: every field gets the default value of its type, and tests register responses as needed. The following serves a fake of the Google Cloud Run API. The googleapis repository cannot be built as a whole, so `--path` narrows the files to build.

```sh
npx grpc-fake-server https://github.com/googleapis/googleapis.git \
  --path google/cloud/run/v2 \
  --service google.cloud.run.v2.Services
```

`npx grpc-fake-server --help` prints the options below, the endpoints, and examples. `npx grpc-fake-server --help <topic>` prints a section of this README in the terminal: `example-values`, `server`, `generate`, `admin-client`, `cel`, or `recipes`.

| Option                     | Default                                                 | Description                                                                                                                            |
| -------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `<input>` (first argument) | `.` (current directory)                                 | A Buf input: a directory with `buf.yaml`, a descriptor set, a Git repository, a BSR module, and so on                                  |
| `--service`                | All services in the non-import files of the built input | The full name of a service to serve. Can be repeated                                                                                   |
| `--path`                   | All files of the input                                  | A file or directory for `buf build` to build. For a local directory input, relative to the current directory. Can be repeated          |
| `--listen`                 | `127.0.0.1:18090`                                       | The address to listen on                                                                                                               |
| `--allow-origin`           | None                                                    | An origin allowed to send requests from browsers. Can be repeated                                                                      |
| `--allow-host`             | None                                                    | A host name accepted in addition to IP addresses and localhost. Can be repeated                                                        |
| `--max-message-bytes`      | 64 MiB                                                  | The limit for requests and responses of the target RPCs                                                                                |
| `--max-calls-per-sequence` | 10,000                                                  | The number of calls kept in the history per sequence ID                                                                                |
| `--max-total-bytes`        | 512 MiB                                                 | The total size of the registrations and the history                                                                                    |
| `--page-target-bytes`      | 16 MiB                                                  | The target size of one page of the call history                                                                                        |
| `--max-admin-bytes`        | 192 MiB                                                 | The limit for requests and responses of the admin service, after encoding. In the JSON format, bytes are counted after base64 encoding |

`GET /readyz` returns 200 after the schema and the declarations are checked and the server starts listening. One port accepts HTTP/1.1 (Connect and gRPC-Web from browsers) and HTTP/2 without TLS (native gRPC).

The server answers 403 without processing the following requests, so that unrelated sites opened in the developer's browser cannot change the registrations or read the history (see [Local Server Security Best Practices](https://green.sapphi.red/blog/local-server-security-best-practices)):

- Requests whose `Host` is not an IP address, `localhost`, a name ending with `.localhost`, or a host name allowed with `--allow-host` (protection against DNS rebinding).
- Requests from browser pages whose `Origin` is not allowed with `--allow-origin`. A request with `Origin`, or with `Sec-Fetch-Site` other than `none`, is treated as a request from a browser page.

When containers in CI connect by service name, such as `http://grpc-fake:18090`, pass `--allow-host grpc-fake`.

### Reloading on Protobuf changes

When the input is a directory, the server watches the `.proto` files and `buf.yaml`, `buf.lock`, and `buf.work.yaml` under it, and switches to the new schema without restarting. `node_modules` and directories starting with `.` are not watched. Other inputs (descriptor sets, Git repositories, BSR modules) are not watched.

- Switching clears the Dynamic registrations and the call history. The server does not switch when the files of the served services and their dependencies did not change.
- When the new schema cannot be built (a syntax error, a `--service` that is not found, or an invalid declaration), the server logs the error and keeps serving the previous schema.
- The typed admin clients are not regenerated automatically. Run `buf generate` after changing RPC definitions. Admin functions for unchanged RPCs keep working.
- Responses registered before regenerating are encoded with the old types. A response with a field removed from the server's schema is rejected at registration, and new fields get default values.

## Generate the admin client

Add the plugin next to Protobuf-ES in `buf.gen.yaml`. The plugin writes `<Service>Fake.ts` for each service into `fake/` under the directory of the `*_pb.ts` files.

```yaml
version: v2
inputs:
  - directory: proto
plugins:
  - local: protoc-gen-es
    out: gen
    opt:
      - target=ts
      - import_extension=ts
  - local: protoc-gen-grpc-fake
    out: gen
    opt:
      # Optional. The extension of the imports in the generated code: ts (default), js, or none.
      - import_extension=ts
```

Run `buf generate` through npm (for example, an npm script) so that `protoc-gen-es` and `protoc-gen-grpc-fake` in `node_modules/.bin` are on `PATH`.

`<Service>Fake.ts` exports a definition for each unary RPC of the service and the admin client. The admin client is embedded in each file, so there is no shared runtime library. At run time, the generated code imports only the `*_pb.ts` of the same service and `@bufbuild/protobuf`. The plugin does not generate code for the admin service and the options (`grpc_fake.v1`) themselves.

## Use the admin client

Tests change the responses for themselves through the admin client. Create it with `create<Service>Fake` from `<Service>Fake.ts`, and pass the RPC definitions from the same file to each operation. The functions are typed to accept only the unary RPCs of that service.

The admin client sends Connect JSON requests with `fetch`. Each operation returns its result on success and throws `GrpcFakeAdminError` on failure, so a failing test stops at the failed line and shows the reason.

The examples below use this service. The same code runs in [examples/vitest](https://github.com/massiv-oss/grpc-fake-server/blob/main/examples/vitest/src/bookService.test.ts).

```proto
service BookService {
  rpc GetBook(GetBookRequest) returns (GetBookResponse);
  rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);
}
```

### Isolate tests with a sequence ID

Registrations and the call history belong to a sequence ID. The application's client sends the sequence ID in the `sequence-id` header, and the server uses the registrations of that ID. Give each test its own ID, and tests can run in parallel against one server without seeing each other's registrations. Requests without the header get the declarative responses.

With Connect-ES, set the header with an interceptor. `GRPC_FAKE_SEQUENCE_ID_HEADER` is exported from the generated code.

```ts
import { createClient } from "@connectrpc/connect";
import type { Interceptor } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-node";

import { BookService } from "./gen/example/v1/book_service_pb.ts";
import {
  createBookServiceFake,
  GRPC_FAKE_SEQUENCE_ID_HEADER,
} from "./gen/example/v1/fake/BookServiceFake.ts";

const baseUrl = "http://127.0.0.1:18090";
const fake = createBookServiceFake({ baseUrl });
const sequenceId = crypto.randomUUID();

const sequenceIdInterceptor: Interceptor = (next) => {
  return (request) => {
    request.header.set(GRPC_FAKE_SEQUENCE_ID_HEADER, sequenceId);
    return next(request);
  };
};
const client = createClient(
  BookService,
  createConnectTransport({
    baseUrl,
    httpVersion: "1.1",
    interceptors: [sequenceIdInterceptor],
  }),
);
```

### Register a response

`registerResponse` registers the whole successful response. The type of `response` is the generated message type without `$typeName` and `$unknown`. Scalars, repeated fields, and maps are required properties, and there is no partial override, so write the whole response; message fields and oneofs can be left unset.

```ts
import { Genre } from "./gen/example/v1/book_service_pb.ts";
import { getBook } from "./gen/example/v1/fake/BookServiceFake.ts";

await fake.registerResponse(getBook, {
  sequenceId,
  response: {
    book: {
      id: "book-1",
      title: "Refactoring",
      genre: Genre.TECHNOLOGY,
      pages: 448,
      price: 47.99,
      inStock: true,
      tags: [],
      ratings: {},
      isbn: "978-0-13-475759-9",
      format: { case: undefined },
    },
  },
});

const { book } = await client.getBook({ bookId: "book-1" });
// book.title === "Refactoring"
```

### Match requests with a condition

`when` is a [CEL](#input-conditions-cel) expression over the request, referred to as `req` with the Protobuf field names. A registration is used only for the calls that match. Calls that match no registration get the declarative response.

```ts
await fake.registerResponse(getBook, {
  sequenceId,
  ruleId: "out-of-print",
  when: 'req.book_id == "out-of-print"',
  response: { book: { ...outOfPrintBook, inStock: false } },
});

await client.getBook({ bookId: "out-of-print" }); // the registered response
await client.getBook({ bookId: "other" }); // the declarative response
```

`ruleId` names a registration. Registering again with the same `ruleId` replaces it, and different `ruleId`s add registrations side by side. Without `ruleId`, the registration is named `default`, so a second registration without `ruleId` replaces the first.

### Return an RPC error

`registerError` makes the RPC fail with a gRPC status code. `code` is the number of the status code. The generated file exports `GrpcFakeCode`, whose names and values match `Code` of Connect-ES, so either works.

```ts
import {
  GrpcFakeCode,
  getBook,
} from "./gen/example/v1/fake/BookServiceFake.ts";

await fake.registerError(getBook, {
  sequenceId,
  code: GrpcFakeCode.NotFound,
  message: "book not found",
});

await client.getBook({ bookId: "missing" });
// throws ConnectError { code: Code.NotFound, rawMessage: "book not found" }
```

### Return error details

`details` attaches error details to the RPC error, the Protobuf messages that a server puts in `google.rpc.Status`. Each detail is a pair of a message descriptor and its value, the same shape that `ConnectError` of Connect-ES accepts. The value is type-checked against its own descriptor. The message type must be in the schema that the server loaded.

```ts
await fake.registerError(getBook, {
  sequenceId,
  code: GrpcFakeCode.NotFound,
  message: "book not found",
  details: [{ desc: BookNotFoundSchema, value: { bookId: "missing" } }],
});

const error = await client.getBook({ bookId: "missing" }).catch((cause) => {
  return ConnectError.from(cause);
});
error.findDetails(BookNotFoundSchema); // [{ bookId: "missing", ... }]
```

The client receives the details the same way as from a real server: in the `grpc-status-details-bin` trailer for gRPC and gRPC-Web, and in the error JSON for Connect. In the call history, the details of an error result are pairs of a type name and a Protobuf binary. Decode them with `fromBinary`.

### Combine registrations with priorities

Registrations with a larger `priority` are evaluated first (the default is 0). Use this for a specific case on top of a general one.

```ts
// The general case: every ListBooks call returns an empty list.
await fake.registerResponse(listBooks, {
  sequenceId,
  ruleId: "empty",
  response: { books: [] },
});

// The specific case: the fiction genre fails. It is evaluated before "empty".
await fake.registerError(listBooks, {
  sequenceId,
  ruleId: "fiction-unavailable",
  when: `req.genre == ${Genre.FICTION}`,
  priority: 10,
  code: GrpcFakeCode.Unavailable,
  message: "fiction is unavailable",
});
```

### Check the calls

`getCalls` returns the requests that the server received for the sequence ID, together with the result returned to each one. Use it to check what the application sent.

```ts
const page = await fake.getCalls(getBook, { sequenceId });

page.totalCount; // 2
page.calls.map((call) => {
  return {
    bookId: call.request.bookId,
    // "dynamic" for a registration, "declarative" for the example values
    source: call.source,
    // "success" or "error"
    kind: call.result.kind,
  };
});
```

The history is paged. Pass `nextCursor` back as `cursor` to read the next page, and `limit` to change the page size (100 by default, at most 1,000).

```ts
let cursor: string | undefined;
do {
  const page = await fake.getCalls(getBook, {
    sequenceId,
    limit: 500,
    ...(cursor === undefined ? {} : { cursor }),
  });
  for (const call of page.calls) {
    // ...
  }
  cursor = page.nextCursor;
} while (cursor !== undefined);
```

`pendingCount` is the number of calls that the server received but has not answered yet. `diagnostics` reports misconfigurations, such as a condition that failed to evaluate on a real request.

### Reset

`reset` deletes the registrations and the call history of a sequence ID. With a new sequence ID for each test, you do not need it; use it when a test reuses an ID.

```ts
await fake.reset({ sequenceId });
```

### Handle admin errors

Use `type` of `GrpcFakeAdminError` to tell the failures apart.

```ts
import { GrpcFakeAdminError } from "./gen/example/v1/fake/BookServiceFake.ts";

try {
  await fake.registerResponse(getBook, {
    sequenceId,
    // Fails: GetBookRequest has no field named unknown_field.
    when: "req.unknown_field == 1",
    response: { book: undefined },
  });
} catch (error) {
  if (error instanceof GrpcFakeAdminError && error.type === "InvalidRequest") {
    console.error(error.message);
  }
}
```

| `type`               | Meaning                                                                                                                             |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `InvalidRequest`     | Invalid registration data or arguments                                                                                              |
| `PreconditionFailed` | An RPC that the server does not serve, or a cursor invalidated by a reset                                                           |
| `LimitExceeded`      | A limit was exceeded: response size, expression size, storage, and so on                                                            |
| `TransportFailed`    | The request failed, or the server failed unexpectedly. When the server returned a code, `code` has the name of the gRPC status code |
| `InvalidResponse`    | The response or the history returned by the server cannot be read                                                                   |

## Recipes

### Start the server from Vitest

Start the server once in a [global setup](https://vitest.dev/config/globalsetup) file and wait for `/readyz`. [examples/vitest](https://github.com/massiv-oss/grpc-fake-server/tree/main/examples/vitest) is a complete project.

```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globalSetup: ["src/startFakeServer.ts"],
  },
});
```

```ts
// src/startFakeServer.ts
import { spawn } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";

export default async (): Promise<() => void> => {
  const server = spawn(
    "grpc-fake-server",
    [".", "--service", "example.v1.BookService", "--listen", "127.0.0.1:18091"],
    { stdio: ["ignore", "ignore", "inherit"] },
  );
  for (let i = 0; i < 300; i++) {
    const ready = await fetch("http://127.0.0.1:18091/readyz").then(
      (response) => response.ok,
      () => false,
    );
    if (ready) {
      return () => {
        server.kill();
      };
    }
    await sleep(200);
  }
  throw new Error("grpc-fake-server did not become ready");
};
```

### Use it with Playwright

Start the server with the [`webServer`](https://playwright.dev/docs/test-webserver) option, and point the web app to it. Browsers send Connect or gRPC-Web requests, so allow the origin of the web app.

```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  webServer: [
    {
      command:
        "grpc-fake-server . --service example.v1.BookService --allow-origin http://localhost:3000",
      url: "http://127.0.0.1:18090/readyz",
      reuseExistingServer: !process.env.CI,
    },
    {
      // The web app reads the URL of the API server from an environment variable.
      command: "API_BASE_URL=http://127.0.0.1:18090 npm run dev",
      url: "http://localhost:3000",
      reuseExistingServer: !process.env.CI,
    },
  ],
});
```

When the web app does not forward a header of its own, [`setExtraHTTPHeaders`](https://playwright.dev/docs/api/class-page#page-set-extra-http-headers) adds the sequence ID to every request of the page, including the RPCs. Register the responses before the page loads.

```ts
import { expect, test } from "@playwright/test";

import {
  createBookServiceFake,
  getBook,
  GRPC_FAKE_SEQUENCE_ID_HEADER,
} from "../gen/example/v1/fake/BookServiceFake.ts";

const fake = createBookServiceFake({ baseUrl: "http://127.0.0.1:18090" });

test("shows that the book is out of stock", async ({ page }) => {
  const sequenceId = crypto.randomUUID();
  await page.setExtraHTTPHeaders({
    [GRPC_FAKE_SEQUENCE_ID_HEADER]: sequenceId,
  });
  await fake.registerResponse(getBook, {
    sequenceId,
    response: { book: { ...book, inStock: false } },
  });

  await page.goto("/books/book-1");

  await expect(page.getByText("Out of stock")).toBeVisible();
});
```

### Run in a container

Inside a container, listen on all interfaces. When other containers connect by service name, allow that host name, because the server rejects unknown `Host` headers.

```yaml
# compose.yaml
services:
  grpc-fake:
    image: node:24
    working_dir: /app
    volumes:
      - .:/app
    command: >-
      npx grpc-fake-server .
      --service example.v1.BookService
      --listen 0.0.0.0:18090
      --allow-host grpc-fake
  app:
    build: .
    environment:
      API_BASE_URL: http://grpc-fake:18090
```

### Use the admin service from other languages

The admin service is defined in `proto/grpc_fake/v1/admin.proto` in this package, and the server serves it over Connect, gRPC-Web, and gRPC like the target services. Tests in other languages can generate a client from it. Successful responses and call inputs are exchanged as `TypedMessage`: the Protobuf binary of the target RPC's type with its type name. The client and the server do not negotiate versions, so generate the client from the same version of the package as the server.

Operations that do not carry a message, such as registering an error or resetting, also work with plain Connect JSON requests:

```sh
# Make GetBook fail with NOT_FOUND (5) for the sequence ID "test-1".
curl -X POST http://127.0.0.1:18090/grpc_fake.v1.FakeAdminService/Register \
  -H "content-type: application/json" \
  -d '{"sequenceId": "test-1", "method": "example.v1.BookService.GetBook", "error": {"code": 5, "message": "not found"}}'

# Delete the registrations and the history of "test-1".
curl -X POST http://127.0.0.1:18090/grpc_fake.v1.FakeAdminService/Reset \
  -H "content-type: application/json" \
  -d '{"sequenceId": "test-1"}'
```

## Input conditions (CEL)

Input conditions are evaluated with [@bufbuild/cel](https://github.com/bufbuild/cel-es) directly on Protobuf-ES messages. Each evaluation is stopped after 100 milliseconds with the timeout of the Node.js `vm` module. A timeout or an evaluation error is reported as a misconfiguration, not as a non-match.

- Refer to the fields of `req` by their Protobuf field names (for example, `req.user_id`).
- Unset fields read as their default values. `has()` follows Protobuf field presence.
- Well-known types follow the CEL specification: `Timestamp` is `timestamp`, `Duration` is `duration`, wrapper types are the wrapped value or `null`, and `Struct`, `Value`, and `ListValue` are JSON values.
- Comparing values of different types with `==`, `!=`, or `in` is an evaluation error, not false. Integers, unsigned integers, and floating-point numbers can be compared with each other and with `null`.
- Functions whose result changes on each evaluation, such as `now()`, are not available.

At registration, the server checks the syntax and evaluates the expression once with a request whose fields are all unset. Unknown fields, undefined names, functions with wrong argument types, comparisons of different types, and non-boolean results fail in this evaluation, and the registration is rejected. References that fail on an empty request are rejected too, so check the size or the key before you access an element: for example, `req.fields.size() > 0 && req.fields[0] == "a"` or `"math" in req.scores && req.scores["math"] == 90`. Mistakes in branches that this evaluation does not reach (such as the right side of `&&`) are reported as misconfigurations when a real request evaluates that branch.

## Development

See the [repository README](https://github.com/massiv-oss/grpc-fake-server/blob/main/README.md#development).

## License

MIT

---
_Source: https://npm.io/package/@massiv-oss/grpc-fake-server · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
