Skip to content

Autogenerate Twirp Clients and Servers in JavaScript or TypeScript

License

Notifications You must be signed in to change notification settings

antorct/TwirpScript

Β 
Β 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

81 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

TwirpScript

A simple RPC framework for JavaScript and TypeScript

What is this? 🧐

TwirpScript is a JavaScript/TypeScript implementation of Twirp. TwirpScript autogenerates clients and server handlers from protocol buffers.

TwirpScript generates JavaScript or TypeScript. TwirpScript can autogenerate:

Overview

TwirpScript is an implementation of the Twirp wire protocol for JavaScript and TypeScript. It generates idiomatic clients and servers from .proto service specifications. The generated clients can be used in the browser. This enables type safe communication between the client and server, as well as reduced payload sizes when using protobuf as the serialization format.

Twirp is a simple RPC framework built on protocol buffers. You define a service in a .proto specification file, and Twirp will generate clients and service scaffolding for that service. You fill in the business logic that powers the server, and Twirp handles the boilerplate.

To learn more about the motivation behind Twirp (and a comparison to REST APIs and gRPC), check out the announcement blog.

FAQ

Why use Twirp instead of GraphQL, gRPC or REST?

For multiple clients with distinct views, I would pull in GraphQL. For a single UI client I prefer the simpler architecture (and well defined optimization paths) found with a more traditional API server approach.

A REST or JSON API lacks type safety and the developer experience that comes from static typing. This can be mitigated to an extent with tools like JSON Schema, but that route requires stitching together (and maintaining) a suite of tools to achieve a similar developer experience.

gRPC is great, but has a large runtime (and corresponding complexity) that is unnecessary for some applications. Twirp offers many of the benefits of gRPC with a significantly reduced runtime. TwirpScript's developer experience is designed to be idiomatic for the JS/TS community, and TwirpScript's autogenerated clients are optimized for use in the browser.

To learn more about the motivation behind Twirp (and a comparison to REST APIs and gRPC), check out the announcement blog.

Highlights πŸ› 

  1. TwirpScript clients can be consumed in the browser (or server*) and are built with tree shaking in mind so only the service methods consumed by the client end up in the final bundle.

  2. The only runtime dependency is Google's protobuf js. If you decide to use JSON instead of Protobuf as the serialization format, this dependency will be removed from clients via tree shaking.

  3. Clients get in-editor API documentation. Comments in your .proto files become TSDoc comments in the generated code that will show inline documentation in supported editors.

  4. Generates idiomatic JavaScript interfaces. None of the Java idioms that protoc --js_out generates such as the List suffix naming for repeated fields or the various getter and setter methods. TwirpScript generates and consumes plain JavaScript objects over classes.

* Requires that the runtime provides fetch. See caveats, warnings and issues for more details.

Installation πŸ“¦

  1. Install the protocol buffers compiler:

    MacOS: brew install protobuf

    Linux: apt install -y protobuf-compiler

    Windows: choco install protoc

    Or install from a precompiled binary.

  2. Add this package to your project: yarn add twirpscript

Getting Started

Overview πŸ“–

To make a Twirp service:

  1. Define your service in a .proto file.
  2. Run yarn twirpscript to generate JavaScript or TypeScript code from your .proto file. This will generate JSON and Protobuf clients, a service interface, and server utilities.
  3. Implement the generated service interface to build your service.
  4. Attach your implemented service to your application server.
  5. Use the generated client to make requests to your server.

1. Define your service

Create a proto specification file:

src/server/haberdasher/haberdasher.proto

syntax = "proto3";

// Haberdasher service makes hats for clients.
service Haberdasher {
  // MakeHat produces a hat of mysterious, randomly-selected color!
  rpc MakeHat(Size) returns (Hat);
}

// Size of a Hat, in inches.
message Size {
  int32 inches = 1; // must be > 0
}

// A Hat is a piece of headwear made by a Haberdasher.
message Hat {
  int32 inches = 1;
  string color = 2; // anything but "invisible"
  string name = 3; // i.e. "bowler"
}

2. Run yarn twirpscript

This will generate haberdasher.pb.ts (or haberdasher.pb.js for JavaScript users) in the same directory as as haberdasher.proto. Any comments will become TSDoc comments and will show inline in supported editors.

yarn twirpscript will compile all.proto files in your project.

3. Implement the generated service interface to build your service.

src/server/haberdasher/index.ts

import { HaberdasherService, createHaberdasherHandler } from "./service.pb";

const Haberdasher: HaberdasherService = {
  MakeHat: (size) => {
    return {
      inches: size.inches,
      color: "red",
      name: "fedora",
    };
  },
};

export const HaberdasherHandler = createHaberdasherHandler(HaberdasherService);

4. Attach your implemented service to your application server.

src/server/index.ts

import { createServer } from "http";
import { createTwirpServer } from "twirpscript";
import { HaberdasherHandler } from "./haberdasher";

const PORT = 8080;

const app = createTwirpServer([HaberdasherHandler]);

createServer(app).listen(PORT, () =>
  console.log(`Server listening on port ${PORT}`)
);

5. Client

That's it for the server! Now you can use the generated clients to make json or protobuf requests to your server:

src/client.ts

import { MakeHat } from "./server/haberdasher/haberdasher.pb";

const hat = await MakeHat({ inches: 12 }, { baseURL: "http://localhost:8080" });
console.log(hat);

baseURL can be globally configured, instead of providing it for every RPC call site:

import { client } from "twirpscript";

// http://localhost:8080 is now the default `baseURL` for _all_ TwirpScript RPCs
client.baseURL = "http://localhost:8080";

const hat = await MakeHat({ inches: 12 }); // We can omit `baseURL` because it has already been set
console.log(hat);

You can override a globally configured baseURL at the RPC call site:

import { client } from "twirpscript";
client.baseURL = "http://localhost:8080";

// This RPC will make a request to https://api.example.com instead of http://localhost:8080
const hat = await MakeHat({ inches: 12 }, { baseURL: "https://api.example.com");
console.log(hat);

// This RPC will make a request to http://localhost:8080
const otherHat = await MakeHat({ inches: 12 });
console.log(otherHat);

Client middleware can override both global and call site settings:

import { client } from "twirpscript";

client.baseURL = "http://localhost:8080";

client.use((config, next) => {
  return next({ ...config, baseURL: "https://www.foo.com" });
});

// This will make a request to https://www.foo.com instead of http://localhost:8080 or https://api.example.com"
const hat = await MakeHat({ inches: 12 }, { baseURL: "https://api.example.com");
console.log(hat);

The order of precedence is global configuration < call site configuration < middleware.

In addtion to baseUrl, headers can also be set at via global configuration, call site configuration and middleware. headers defines key value pairs that become HTTP headers for the RPC:

import { client } from "twirpscript";

client.baseURL = "http://localhost:8080";

// setting a (non standard) HTTP "device-id" header via global configuration. This header will be sent for every RPC.
client.headers = { "device-id": getOrGenerateDeviceId() };

// setting an HTTP "authorization" header via middleware. This header will also be sent for every RPC.
client.use((config, next) => {
  const auth = localStorage.getItem("auth");
  if (auth) {
    config.headers["authorization"] = `bearer ${auth}`;
  }
  return next(config);
});

// setting a (non standard) HTTP "idempotency-key" header for this RPC call. This header will only be sent for this RPC.
const hat = await MakeHat({ inches: 12 }, { headers: { "idempotency-key": "foo" } });
console.log(hat);

headers defined via global and call site configuration will merge. Call site key collisions override header keys defined globally (global configuration < call site configuration). Similiar to baseURL middleware can override, omit or otherwise manipulate the headers in any way.

Connecting to an existing Twirp server and only need a JavaScript or TypeScript client?

  1. Get your service's .proto file (or define one).
  2. Run yarn twirpscript to generate JavaScript or TypeScript code from your .proto file.
  3. Use the generated client to make requests to your server.

Middleware / Interceptors

TwirpScript's client and server runtimes can be configured via middleware.

Client

Clients can be configured via the client export's use method. use registers middleware to manipulate the client request / response lifecycle. The middleware handler will receive config and next parameters. config sets the headers and url for the RPC. next invokes the next handler in the chain -- either the next registered middleware, or the Twirp RPC.

Middleware is called in order of registration, with the Twirp RPC invoked last.

Because each middleware is responsible for invoking the next handler, middleware can do things like short circuit and return a response before the RPC is made, or inspect the returned response, enabling powerful patterns such as caching.

Client Middleware Example:

import { client } from "twirpscript";

client.use((config, next) => {
  const auth = localStorage.getItem("auth");
  if (auth) {
    config.headers["authorization"] = `bearer ${auth}`;
  }
  return next(config);
});

Server

Servers can be configured via your server's use method. use registers middleware to manipulate the server request / response lifecycle.

The middleware handler will receive req, ctx and next parameters. req is the incoming request. ctx is a request context object which will be passed to each middleware handler and finally the Twirp service handler you implemented. ctx enables you to pass extra parameters to your service handlers that are not available via your service's defined request parameters, and can be used to implement things such as authentication or rate limiting. next invokes the next handler in the chain -- either the next registered middleware, or the Twirp service handler you implemented.

Middleware is called in order of registration, with the Twirp service handler you implemented invoked last.

Because each middleware is responsible for invoking the next handler, middleware can do things like short circuit and return a response before your service handler is invoked, or inspect the returned response, enabling powerful patterns such as caching.

Server Middleware Example

import { createServer } from "http";
import { createTwirpServer } from "twirpscript";

interface Context {
  currentUser: { username: string };
}

const app = createTwirpServer<Context>([AuthenticationHandler]);

app.use(async (req, ctx, next) => {
  if (req.url?.startsWith(`/twirp/${AuthenticationHandler.path}`)) {
    return next();
  }

  const token = req.headers["authorization"]?.split("bearer")?.[1]?.trim();
  ctx.currentUser = getCurrentUser(token);

  if (!ctx.currentUser) {
    return TwirpErrorResponse({
      code: "unauthenticated",
      msg: "Access denied",
    });
  } else {
    return next();
  }
};

createServer(app).listen(PORT, () =>
  console.log(`Server listening on port ${PORT}`)
);

Configuration πŸ› 

TwirpScript aims to be zero config, but can be configured by creating a .twirp.json file in your project root.

Name Description Type
src The directory to search for `.proto` files. TwirpScript will recursively search all subdirectories. Defaults to the project root. string
target Whether to generate JavaScript or TypeScript. By default, TwirpScript will attempt to autodetect the target by looking for a `tsconfig.json` in the project root. If found, TwirpScript will generate TypeScript, otherwise JavaScript. javascript | typescript

Examples πŸš€

The documentation is a work in progress. Checkout the examples in the examples directory:

The examples also demonstrate testing using jest.

Caveats, Warnings and Issues ⚠️

Fetch

The autogenerated clients use fetch so your runtime must include fetch. See a Node.js client example from the clientcompat test.

JavaScript Servers (does not apply to servers written in TypeScript)

JavaScript Server implementations require special consideration. The NodeJS ecosystem is in a transition period from CommonJS to modules. TwirpScript generates JavaScript modules to enable tree shaking for clients. This means that NodeJS servers either need to opt-in to modules, or use a bundler like Webpack or ESBuild. See the JavaScript fullstack to see what this looks like.

This rough edge is under active consideration. If you have thoughts, feel free to open an issue or pull request.

Note that this does not apply to TypeScript servers, because TypeScript will compile the ES modules to CommonJS when targeting NodeJS. Servers written in TypeScript will "just work".

Contributing πŸ‘«

PR's and issues welcomed! For more guidance check out CONTRIBUTING.md

Licensing πŸ“ƒ

See the project's MIT License.

About

Autogenerate Twirp Clients and Servers in JavaScript or TypeScript

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • TypeScript 90.6%
  • JavaScript 9.3%
  • Batchfile 0.1%