├── .gitignore ├── test ├── .gitignore ├── index.ts ├── tsconfig.json ├── service.proto ├── async-server.ts └── index.test.ts ├── src ├── index.ts ├── index.hbs ├── client.test.ts ├── server.hbs ├── client.hbs ├── run.ts ├── server-runtime.ts ├── client.ts └── errors.ts ├── prettier.config.js ├── jest.config.js ├── .github └── workflows │ └── ci.yml ├── tsconfig.json ├── .eslintrc.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | index.ts 2 | service.pb* 3 | service.twirp.ts 4 | server.ts 5 | client.ts 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as errors from './errors'; 2 | export { errors }; 3 | export * from './server-runtime'; 4 | export * from './client'; 5 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "all", 3 | printWidth: 100, 4 | tabWidth: 2, 5 | semi: true, 6 | singleQuote: true, 7 | }; 8 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import pb from './service.pb'; 2 | import example = pb.twitch.twirp.example; 3 | export { example }; 4 | export * from './server'; 5 | export * from './client'; 6 | -------------------------------------------------------------------------------- /src/index.hbs: -------------------------------------------------------------------------------- 1 | import pb from './{{protoFilename}}.pb'; 2 | import {{shortNamespace}} = pb.{{namespace}}; 3 | export { {{shortNamespace}} }; 4 | export * from './server'; 5 | export * from './client'; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | moduleNameMapper: { 5 | '^ts-twirp$': '/src', 6 | '^ts-twirp\/(.*)$': '/src/$1', 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "baseUrl": "./", 5 | "strict": true, 6 | "paths": { 7 | "ts-twirp": ["../src"], 8 | "ts-twirp/*": ["../src/*"] 9 | }, 10 | }, 11 | "exclude": [] 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - uses: actions/setup-node@v1 9 | with: 10 | node-version: 12.x 11 | - run: npm ci 12 | - run: npm run lint 13 | - run: npm test 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "ts-twirp": ["./src"], 12 | "ts-twirp/*": ["./src/*"] 13 | }, 14 | }, 15 | "exclude": ["./test", "./dist"] 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | ignorePatterns: ["node_modules", "dist", "*.d.ts"], 5 | plugins: [ 6 | '@typescript-eslint', 7 | ], 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/eslint-recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | ], 13 | rules: { 14 | "@typescript-eslint/no-use-before-define": ["error", { "functions": false, "classes": false }], 15 | "@typescript-eslint/explicit-function-return-type": "error", 16 | "@typescript-eslint/no-empty-function": "off", 17 | "@typescript-eslint/no-unused-vars": "error", 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /test/service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package twitch.twirp.example; 4 | option go_package = "example"; 5 | 6 | // A Hat is a piece of headwear made by a Haberdasher. 7 | message Hat { 8 | // The size of a hat should always be in inches. 9 | int32 size = 1; 10 | 11 | // The color of a hat will never be 'invisible', but other than 12 | // that, anything is fair game. 13 | string color = 2; 14 | 15 | // The name of a hat is it's type. Like, 'bowler', or something. 16 | string name = 3; 17 | } 18 | 19 | // Size is passed when requesting a new hat to be made. It's always 20 | // measured in inches. 21 | message Size { 22 | int32 inches = 1; 23 | } 24 | 25 | // A Haberdasher makes hats for clients. 26 | service Haberdasher { 27 | // MakeHat produces a hat of mysterious, randomly-selected color! 28 | rpc MakeHat(Size) returns (Hat); 29 | } 30 | -------------------------------------------------------------------------------- /src/client.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import { jsonToMessageProperties } from './client'; 3 | 4 | test('converting JSON responses to message properties', () => { 5 | const response = Buffer.from( 6 | JSON.stringify({ 7 | a_long_key: 'value1', 8 | nested_thing: { 9 | some_number: 5, 10 | deeper_nesting: { 11 | value: null, 12 | }, 13 | }, 14 | array_key: [{ key: 'value' }, { bool: false }, { not_there: null }], 15 | }), 16 | ); 17 | const properties = jsonToMessageProperties(response); 18 | 19 | expect(properties).toEqual({ 20 | aLongKey: 'value1', 21 | nestedThing: { 22 | someNumber: 5, 23 | deeperNesting: { 24 | value: null, 25 | }, 26 | }, 27 | arrayKey: [ 28 | { 29 | key: 'value', 30 | }, 31 | { 32 | bool: false, 33 | }, 34 | { 35 | notThere: null, 36 | }, 37 | ], 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/async-server.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | 3 | interface HTTPHandler { 4 | (request: http.IncomingMessage, response: http.ServerResponse): void; 5 | } 6 | 7 | export class AsyncServer { 8 | public handler: HTTPHandler = () => {}; 9 | private server: http.Server; 10 | 11 | constructor(handler?: HTTPHandler) { 12 | if (handler) { 13 | this.handler = handler; 14 | } 15 | 16 | this.server = http.createServer((req, res) => { 17 | this.handler(req, res); 18 | }); 19 | 20 | this.server.on('clientError', (err, socket) => { 21 | socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); 22 | }); 23 | } 24 | 25 | listen(): Promise { 26 | return new Promise(resolve => { 27 | this.server.listen(8000, () => { 28 | resolve(this); 29 | }); 30 | }); 31 | } 32 | 33 | close(): Promise { 34 | return new Promise((resolve, reject) => { 35 | this.server.close((err) => { 36 | if (err) { 37 | reject(err); 38 | } else { 39 | resolve(); 40 | } 41 | }); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-twirp", 3 | "version": "1.1.3", 4 | "description": "Generate a TypesScript Twirp server", 5 | "main": "dist/index.js", 6 | "repository": "https://github.com/mitchlloyd/ts-twirp", 7 | "author": "Mitch Lloyd ", 8 | "license": "Apache-2.0", 9 | "bin": { 10 | "ts-twirp": "dist/run.js" 11 | }, 12 | "dependencies": { 13 | "@typescript-eslint/eslint-plugin": "^2.14.0", 14 | "@typescript-eslint/parser": "^2.14.0", 15 | "eslint": "^6.8.0", 16 | "espree": "^3.5.4", 17 | "handlebars": "^4.5.3", 18 | "jsdoc": "^3.6.3", 19 | "protobufjs": "6.8.8", 20 | "tmp": "0.0.33" 21 | }, 22 | "files": [ 23 | "dist/**/*" 24 | ], 25 | "devDependencies": { 26 | "@types/ejs": "^2.6.0", 27 | "@types/jest": "^23.3.10", 28 | "@types/node": "^10.12.11", 29 | "@types/request": "^2.48.1", 30 | "@types/request-promise-native": "^1.0.15", 31 | "jest": "^24.7.1", 32 | "prettier": "1.17.1", 33 | "ts-jest": "^24.0.2", 34 | "ts-node": "^7.0.1", 35 | "ts-protoc-gen": "^0.8.0", 36 | "typescript": "^3.2.1" 37 | }, 38 | "scripts": { 39 | "test:gen": "./node_modules/.bin/ts-node src/run.ts -- ./test/service.proto", 40 | "test": "npm run test:gen && npx jest", 41 | "lint": "npx eslint . --ext .ts", 42 | "dist:cp-templates": "find src -name '*.hbs' -exec cp {} dist \\;", 43 | "dist": "rm -rf ./dist && tsc && npm run dist:cp-templates", 44 | "prerelease": "npm run test && npm run dist" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/server.hbs: -------------------------------------------------------------------------------- 1 | import * as pb from './{{protoFilename}}.pb'; 2 | import * as http from 'http'; 3 | import { TwirpContentType, handleRequest } from 'ts-twirp'; 4 | 5 | type MaybePromise = Promise | T; 6 | 7 | export const {{lowercase service}}PathPrefix = '/twirp/{{namespace}}.{{service}}/'; 8 | 9 | export interface RPCHandlers { 10 | {{#each methods as |method|}} 11 | {{lowercase method.name}}(request: pb.{{../namespace}}.{{method.requestType}}): MaybePromise; 12 | {{/each}} 13 | } 14 | 15 | export function create{{service}}Handler(rpcHandlers: RPCHandlers) { 16 | return function {{lowercase service}}Handler(req: http.IncomingMessage, res: http.ServerResponse) { 17 | handleRequest(req, res, route, rpcHandlers); 18 | }; 19 | } 20 | 21 | function route(url: string | undefined, contentType: TwirpContentType) { 22 | switch (url) { 23 | {{#each methods as |method|}} 24 | case '/twirp/{{../namespace}}.{{../service}}/{{method.name}}': 25 | return contentType === TwirpContentType.Protobuf ? handleProtobuf{{method.name}} : handleJSON{{method.name}}; 26 | {{/each}} 27 | default: 28 | return; 29 | } 30 | } 31 | {{#each methods as |method|}} 32 | 33 | async function handleProtobuf{{method.name}}(data: Buffer, handlers: RPCHandlers): Promise { 34 | const request = pb.{{../namespace}}.{{method.requestType}}.decode(data); 35 | const response = await handlers.{{lowercase method.name}}(request); 36 | return pb.{{../namespace}}.{{method.responseType}}.encode(response).finish(); 37 | } 38 | 39 | async function handleJSON{{method.name}}(data: Buffer, handlers: RPCHandlers): Promise { 40 | const json = JSON.parse(data.toString('utf8')); 41 | const request = pb.{{../namespace}}.{{method.requestType}}.fromObject(json); 42 | const response = await handlers.{{lowercase method.name}}(request); 43 | return JSON.stringify(pb.{{../namespace}}.{{method.responseType}}.create(response)); 44 | } 45 | {{/each}} 46 | -------------------------------------------------------------------------------- /src/client.hbs: -------------------------------------------------------------------------------- 1 | import pb from './{{protoFilename}}.pb'; 2 | import {{shortNamespace}} = pb.{{namespace}}; 3 | import { createProtobufRPCImpl, createJSONRPCImpl, JSONRPCImpl } from 'ts-twirp'; 4 | 5 | interface Create{{service}}ClientParams { 6 | /** 7 | * The host portion of the URL to use. 8 | */ 9 | host: string; 10 | 11 | /** 12 | * The port used to call the service. 13 | */ 14 | port: number; 15 | } 16 | 17 | export function create{{service}}ProtobufClient( 18 | params: Create{{service}}ClientParams 19 | ): {{service}} { 20 | const rpcImpl = createProtobufRPCImpl({ 21 | host: params.host, 22 | port: params.port, 23 | path: '/twirp/{{namespace}}.{{service}}/', 24 | }); 25 | 26 | return {{shortNamespace}}.{{service}}.create(rpcImpl, false, false); 27 | } 28 | 29 | export function create{{service}}JSONClient( 30 | params: Create{{service}}ClientParams 31 | ): {{service}} { 32 | const rpcImpl = createJSONRPCImpl({ 33 | host: params.host, 34 | port: params.port, 35 | path: '/twirp/{{namespace}}.{{service}}/', 36 | }); 37 | 38 | return new {{service}}JSONClient(rpcImpl); 39 | } 40 | 41 | export interface {{service}} { 42 | {{#each methods as |method|}} 43 | {{lowercase method.name}}(request: {{../shortNamespace}}.I{{method.requestType}}): Promise<{{../shortNamespace}}.{{method.responseType}}>; 44 | {{/each}} 45 | } 46 | 47 | export class {{service}}JSONClient implements {{service}} { 48 | private rpcImpl: JSONRPCImpl; 49 | 50 | constructor(rpcImpl: JSONRPCImpl) { 51 | this.rpcImpl = rpcImpl; 52 | } 53 | {{#each methods as |method|}} 54 | 55 | public async {{lowercase method.name}}(request: {{../shortNamespace}}.I{{method.requestType}}): Promise<{{../shortNamespace}}.{{method.responseType}}> { 56 | const requestMessage = {{../shortNamespace}}.{{method.requestType}}.create(request); 57 | const response = await this.rpcImpl(requestMessage, '{{method.name}}'); 58 | const verificationError = {{../shortNamespace}}.{{method.responseType}}.verify(response); 59 | if (verificationError) { 60 | return Promise.reject(verificationError); 61 | } 62 | 63 | return {{../shortNamespace}}.{{method.responseType}}.create(response); 64 | } 65 | {{/each}} 66 | } 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ts-twirp 2 | 3 | [![Build Status](https://github.com/mitchlloyd/ts-twirp/workflows/ci/badge.svg)](https://github.com/mitchlloyd/ts-twirp/actions?query=workflow%3Aci+branch%3Amaster) 4 | 5 | ts-twirp is a TypeScript code generator for [Twirp](https://github.com/twitchtv/twirp) servers and clients. 6 | 7 | ## Make a Twirp Server 8 | 9 | Install `ts-twirp`. 10 | 11 | ``` 12 | npm install --save-dev ts-twirp 13 | ``` 14 | 15 | ts-twirp takes an opinionated approach to file organization. Each protobuf 16 | file lives in its own directory and ts-twirp generates sibling files that you 17 | can `.gitignore`. 18 | 19 | Place your server's protobuf file in its own directory. 20 | 21 | ``` 22 | /src 23 | /twirp 24 | service.proto 25 | ``` 26 | 27 | Run `ts-twirp` on `service.proto`. 28 | 29 | ``` 30 | npx ts-twirp src/twirp/service.proto 31 | ``` 32 | 33 | This generates sibling files in the same directory. 34 | 35 | ``` 36 | /src 37 | /twirp 38 | index.ts # Exports the code you'll use to implement your server 39 | service.pb.d.ts # Protobuf types generated from your service.proto file 40 | service.pb.ts # Runtime protobuf serialization/deserialization code 41 | service.proto # The service protobuf definition 42 | server.ts # Runtime TypeScript server code 43 | ``` 44 | 45 | Use `src/twirp/index.ts` as the entry point to the service types and runtime 46 | code. 47 | 48 | ```ts 49 | // ./src/server.ts 50 | import http from 'http'; 51 | import { createHaberdasherHandler } from './twirp'; 52 | 53 | const handler = createHaberdasherHandler({ 54 | async makeHat(size) { 55 | return ({ 56 | color: 'red', 57 | name: 'fancy hat', 58 | size: size.inches 59 | }); 60 | } 61 | }); 62 | 63 | http.createServer(handler).listen(8000); 64 | ``` 65 | 66 | ## Make a Twirp Client 67 | 68 | Running the generator will create both a protobuf and a JSON client. 69 | 70 | Using the protobuf client: 71 | 72 | ```ts 73 | import { createHaberdasherProtobufClient } from './twirp'; 74 | 75 | async function run() { 76 | const haberdasher = createHaberdasherProtobufClient({ 77 | host: 'localhost', 78 | port: 8000, 79 | }); 80 | 81 | const hat = await haberdasher.makeHat({ 82 | inches: 42 83 | }); 84 | 85 | console.log(hat); 86 | } 87 | 88 | run(); 89 | ``` 90 | 91 | As you might expect using the JSON client is nearly identical. 92 | 93 | ```ts 94 | import { createHaberdasherJSONClient } from './twirp'; 95 | 96 | async function run() { 97 | const haberdasher = createHaberdasherJSONClient({ 98 | host: 'localhost', 99 | port: 8000, 100 | }); 101 | 102 | const hat = await haberdasher.makeHat({ 103 | inches: 42 104 | }); 105 | 106 | console.log(hat); 107 | } 108 | 109 | run(); 110 | ``` 111 | -------------------------------------------------------------------------------- /src/run.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { load, Message, Root } from 'protobufjs'; 4 | import * as path from 'path'; 5 | import { readFileSync, writeFileSync } from 'fs'; 6 | import * as Handlebars from 'handlebars'; 7 | import * as descriptor from 'protobufjs/ext/descriptor'; 8 | import 'protobufjs/ext/descriptor'; 9 | import { pbjs, pbts } from 'protobufjs/cli'; 10 | 11 | Handlebars.registerHelper('lowercase', function(text: string) { 12 | return text.charAt(0).toLocaleLowerCase() + text.slice(1); 13 | }); 14 | 15 | interface RootWithToDescriptor extends Root { 16 | toDescriptor(version: string): Protofile; 17 | } 18 | 19 | type Protofile = Message; 20 | 21 | async function run(): Promise { 22 | const protofilePath = process.argv[2]; 23 | if (!protofilePath) { 24 | throw new Error('Provide the path to a service.proto file'); 25 | } 26 | 27 | const fileParts = path.parse(protofilePath); 28 | if (fileParts.ext !== '.proto') { 29 | throw new Error('Path must point to a .proto file'); 30 | } 31 | 32 | const runtimeJSPath = `${fileParts.dir}/${fileParts.name}.pb.js`; 33 | const typeDefsPath = `${fileParts.dir}/${fileParts.name}.pb.d.ts`; 34 | pbjs.main(['-t', 'static-module', '-w', 'commonjs', '-o', runtimeJSPath, protofilePath]); 35 | pbts.main(['-o', typeDefsPath, runtimeJSPath]); 36 | 37 | const root = await load(protofilePath); 38 | root.resolveAll() 39 | const descriptor = (root as RootWithToDescriptor).toDescriptor('proto3'); 40 | const service = root.lookupService(getServiceName(descriptor)); 41 | const namespace = service.fullName 42 | .split('.') 43 | .slice(1, -1) 44 | .join('.'); 45 | const shortNamespace = namespace.split('.').slice(-1); 46 | 47 | const templateContext = { 48 | methods: service.methods, 49 | service: service.name, 50 | namespace, 51 | shortNamespace, 52 | protoFilename: fileParts.name, 53 | }; 54 | 55 | await generateServer(`${fileParts.dir}/server.ts`, templateContext); 56 | await generateClient(`${fileParts.dir}/client.ts`, templateContext); 57 | await generateIndex(`${fileParts.dir}/index.ts`, templateContext); 58 | } 59 | 60 | function generateIndex(indexPath: string, templateContext: {}): void { 61 | const template = readFileSync(path.join(__dirname, 'index.hbs'), 'utf8'); 62 | const hbsTemplate = Handlebars.compile(template); 63 | const tsOutput = hbsTemplate(templateContext); 64 | writeFileSync(indexPath, tsOutput); 65 | } 66 | 67 | async function generateServer(tsServerPath: string, templateContext: {}): Promise { 68 | const template = readFileSync(path.join(__dirname, 'server.hbs'), 'utf8'); 69 | const hbsTemplate = Handlebars.compile(template); 70 | const tsOutput = hbsTemplate(templateContext); 71 | writeFileSync(tsServerPath, tsOutput); 72 | } 73 | 74 | async function generateClient(tsClientPath: string, templateContext: {}): Promise { 75 | const template = readFileSync(path.join(__dirname, 'client.hbs'), 'utf8'); 76 | const hbsTemplate = Handlebars.compile(template); 77 | const tsOutput = hbsTemplate(templateContext); 78 | writeFileSync(tsClientPath, tsOutput); 79 | } 80 | 81 | function getServiceName(protofile: Protofile): string { 82 | return protofile.toJSON().file[0].service[0].name; 83 | } 84 | 85 | run(); 86 | -------------------------------------------------------------------------------- /src/server-runtime.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import * as errors from './errors'; 3 | 4 | export enum TwirpContentType { 5 | Protobuf, 6 | JSON, 7 | Unknown, 8 | } 9 | 10 | type Router = ( 11 | url: string | undefined, 12 | contentType: TwirpContentType, 13 | ) => undefined | TwirpHandler; 14 | export type TwirpHandler = (data: Buffer, rpcHandlers: T) => Promise; 15 | 16 | function getContentType(mimeType: string | undefined): TwirpContentType { 17 | switch (mimeType) { 18 | case 'application/protobuf': 19 | return TwirpContentType.Protobuf; 20 | case 'application/json': 21 | return TwirpContentType.JSON; 22 | default: 23 | return TwirpContentType.Unknown; 24 | } 25 | } 26 | 27 | export async function handleRequest( 28 | req: http.IncomingMessage, 29 | res: http.ServerResponse, 30 | getTwirpHandler: Router, 31 | rpcHandlers: T, 32 | ): Promise { 33 | if (req.method !== 'POST') { 34 | writeError( 35 | res, 36 | new errors.BadRouteError(`unsupported method ${req.method} (only POST is allowed)`), 37 | ); 38 | return; 39 | } 40 | 41 | const contentTypeMimeType = req.headers['content-type']; 42 | if (!contentTypeMimeType) { 43 | writeError(res, new errors.BadRouteError(`missing Content-Type header`)); 44 | return; 45 | } 46 | 47 | const contentType = getContentType(contentTypeMimeType); 48 | if (contentType === TwirpContentType.Unknown) { 49 | writeError(res, new errors.BadRouteError(`unexpected Content-Type: ${contentTypeMimeType}`)); 50 | return; 51 | } 52 | 53 | const handler = getTwirpHandler(req.url, contentType); 54 | if (!handler) { 55 | writeError(res, new errors.BadRouteError(`no handler for path ${req.url}`)); 56 | return; 57 | } 58 | 59 | let requestData; 60 | try { 61 | requestData = await getRequestData(req); 62 | } catch (e) { 63 | writeError(res, e); 64 | return; 65 | } 66 | 67 | let responseData; 68 | try { 69 | responseData = await handler(requestData, rpcHandlers); 70 | } catch (e) { 71 | writeError(res, e); 72 | return; 73 | } 74 | 75 | res.setHeader('Content-Type', contentType); 76 | res.end(responseData); 77 | } 78 | 79 | export function getRequestData(req: http.IncomingMessage): Promise { 80 | return new Promise((resolve, reject) => { 81 | const chunks: Buffer[] = []; 82 | req.on('data', chunk => chunks.push(chunk)); 83 | req.on('end', async () => { 84 | const data = Buffer.concat(chunks); 85 | resolve(data); 86 | }); 87 | req.on('error', err => { 88 | reject(err); 89 | }); 90 | }); 91 | } 92 | 93 | export function writeError(res: http.ServerResponse, error: Error | errors.TwirpError): void { 94 | res.setHeader('Content-Type', 'application/json'); 95 | 96 | let twirpError: errors.TwirpError; 97 | if ('isTwirpError' in error) { 98 | twirpError = error; 99 | } else { 100 | twirpError = new errors.InternalServerError(error.message); 101 | } 102 | 103 | res.statusCode = twirpError.statusCode; 104 | res.end( 105 | JSON.stringify({ 106 | code: twirpError.name, 107 | msg: twirpError.message, 108 | }), 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { RPCImpl, util } from 'protobufjs'; 2 | import http from 'http'; 3 | 4 | interface CreateTwirpRPCImplParams { 5 | host: string; 6 | port: number; 7 | path: string; 8 | } 9 | 10 | export function createProtobufRPCImpl(params: CreateTwirpRPCImplParams): RPCImpl { 11 | const rpcImpl: RPCImpl = (method, requestData, callback) => { 12 | const chunks: Buffer[] = []; 13 | const req = http 14 | .request( 15 | { 16 | hostname: params.host, 17 | port: params.port, 18 | path: params.path + method.name, 19 | method: 'POST', 20 | headers: { 21 | 'Content-Type': 'application/protobuf', 22 | 'Content-Length': Buffer.byteLength(requestData), 23 | }, 24 | }, 25 | res => { 26 | res.on('data', chunk => chunks.push(chunk)); 27 | res.on('end', () => { 28 | const data = Buffer.concat(chunks); 29 | if (res.statusCode != 200) { 30 | callback(getTwirpError(data), null); 31 | } else { 32 | callback(null, data); 33 | } 34 | }); 35 | res.on('error', err => { 36 | callback(err, null); 37 | }); 38 | }, 39 | ) 40 | .on('error', err => { 41 | callback(err, null); 42 | }); 43 | 44 | req.end(requestData); 45 | }; 46 | 47 | return rpcImpl; 48 | } 49 | 50 | interface JSONReadyObject { 51 | toJSON: () => { [key: string]: unknown }; 52 | } 53 | 54 | export type JSONRPCImpl = (obj: JSONReadyObject, methodName: string) => Promise<{}>; 55 | 56 | export function createJSONRPCImpl(params: CreateTwirpRPCImplParams): JSONRPCImpl { 57 | return function doJSONRequest(obj: JSONReadyObject, methodName: string): Promise<{}> { 58 | const json = JSON.stringify(obj); 59 | 60 | return new Promise((resolve, reject) => { 61 | const chunks: Buffer[] = []; 62 | const req = http 63 | .request( 64 | { 65 | hostname: params.host, 66 | port: params.port, 67 | path: params.path + methodName, 68 | method: 'POST', 69 | headers: { 70 | 'Content-Type': 'application/json', 71 | 'Content-Length': json.length, 72 | }, 73 | }, 74 | res => { 75 | res.on('data', chunk => chunks.push(chunk)); 76 | res.on('end', () => { 77 | const data = Buffer.concat(chunks); 78 | if (res.statusCode != 200) { 79 | reject(getTwirpError(data)); 80 | } else { 81 | resolve(jsonToMessageProperties(data)); 82 | } 83 | }); 84 | res.on('error', err => { 85 | reject(err); 86 | }); 87 | }, 88 | ) 89 | .on('error', err => { 90 | reject(err); 91 | }); 92 | 93 | req.end(json); 94 | }); 95 | }; 96 | } 97 | 98 | function getTwirpError(data: Uint8Array): Error { 99 | const json = JSON.parse(data.toString()); 100 | const error = new Error(json.msg); 101 | error.name = json.code; 102 | 103 | return error; 104 | } 105 | 106 | export function jsonToMessageProperties(buffer: Buffer): JSONObject { 107 | const json = buffer.toString(); 108 | const obj = JSON.parse(json); 109 | 110 | return camelCaseKeys(obj); 111 | } 112 | 113 | function camelCaseKeys(obj: JSONObject): JSONObject { 114 | let newObj: JSONObject; 115 | if (Array.isArray(obj)) { 116 | return obj.map(value => { 117 | if (isJSONObject(value)) { 118 | value = camelCaseKeys(value); 119 | } 120 | return value; 121 | }); 122 | } else { 123 | newObj = {}; 124 | for (const [key, value] of Object.entries(obj)) { 125 | let camelizedValue = value; 126 | if (isJSONObject(value)) { 127 | camelizedValue = camelCaseKeys(value); 128 | } 129 | newObj[util.camelCase(key)] = camelizedValue; 130 | } 131 | } 132 | 133 | return newObj; 134 | } 135 | 136 | type JSONObject = { [key: string]: unknown } | unknown[]; 137 | 138 | function isJSONObject(value: unknown): value is JSONObject { 139 | return Array.isArray(value) || (value !== null && typeof value === 'object'); 140 | } 141 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as pb from './service.pb'; 2 | import Example = pb.twitch.twirp.example; 3 | import { AsyncServer } from './async-server'; 4 | import request from 'request-promise-native'; 5 | import { 6 | createHaberdasherProtobufClient, 7 | createHaberdasherHandler, 8 | haberdasherPathPrefix, 9 | } from './index'; 10 | import { createHaberdasherJSONClient, Haberdasher } from './client'; 11 | 12 | let protobufClient: Haberdasher; 13 | let jsonClient: Haberdasher; 14 | let server: AsyncServer; 15 | 16 | beforeAll(async () => { 17 | server = await new AsyncServer().listen(); 18 | 19 | protobufClient = createHaberdasherProtobufClient({ 20 | host: 'localhost', 21 | port: 8000, 22 | }); 23 | 24 | jsonClient = createHaberdasherJSONClient({ 25 | host: 'localhost', 26 | port: 8000, 27 | }); 28 | }); 29 | 30 | afterAll(async () => { 31 | await server.close(); 32 | }); 33 | 34 | test('Handling a Twirp protobuf call', async () => { 35 | server.handler = createHaberdasherHandler({ 36 | makeHat(size: Example.Size) { 37 | return { 38 | color: 'red', 39 | name: 'fancy hat', 40 | size: size.inches, 41 | }; 42 | }, 43 | }); 44 | 45 | const response = await protobufClient.makeHat({ 46 | inches: 42, 47 | }); 48 | 49 | expect(response).toEqual({ 50 | size: 42, 51 | name: 'fancy hat', 52 | color: 'red', 53 | }); 54 | }); 55 | 56 | test('Handling a Twirp JSON call', async () => { 57 | server.handler = createHaberdasherHandler({ 58 | makeHat(size: Example.Size) { 59 | return { 60 | color: 'red', 61 | name: 'fancy hat', 62 | size: size.inches, 63 | }; 64 | }, 65 | }); 66 | 67 | const response = await jsonClient.makeHat({ 68 | inches: 42, 69 | }); 70 | 71 | expect(response).toEqual({ 72 | size: 42, 73 | name: 'fancy hat', 74 | color: 'red', 75 | }); 76 | }); 77 | 78 | test('Protobuf error is returned as JSON', async () => { 79 | server.handler = createHaberdasherHandler({ 80 | makeHat(/* size: Example.Size */) { 81 | throw new Error('thrown!'); 82 | }, 83 | }); 84 | 85 | const request = protobufClient.makeHat({ 86 | inches: 42, 87 | }); 88 | 89 | await expect(request).rejects.toEqual( 90 | expect.objectContaining({ 91 | message: 'thrown!', 92 | name: 'internal', 93 | }), 94 | ); 95 | }); 96 | 97 | test('Missing route returns 404', async () => { 98 | server.handler = createHaberdasherHandler({ 99 | makeHat(size: Example.Size) { 100 | return { 101 | color: 'red', 102 | name: 'fancy hat', 103 | size: size.inches, 104 | }; 105 | }, 106 | }); 107 | 108 | const response = await request(`http://localhost:8000${haberdasherPathPrefix}MakePants`, { 109 | body: JSON.stringify({ 110 | inches: 42, 111 | }), 112 | headers: { 113 | 'Content-Type': 'application/json', 114 | }, 115 | resolveWithFullResponse: true, 116 | simple: false, 117 | method: 'POST', 118 | }); 119 | 120 | expect(response.statusCode).toEqual(404); 121 | expect(response.headers['content-type']).toEqual('application/json'); 122 | const body = JSON.parse(response.body); 123 | expect(body).toEqual({ 124 | code: 'bad_route', 125 | msg: `no handler for path ${haberdasherPathPrefix}MakePants`, 126 | }); 127 | }); 128 | 129 | test('Unknown content type returns 404', async () => { 130 | server.handler = createHaberdasherHandler({ 131 | makeHat(size: Example.Size) { 132 | return { 133 | color: 'red', 134 | name: 'fancy hat', 135 | size: size.inches, 136 | }; 137 | }, 138 | }); 139 | 140 | const response = await request(`http://localhost:8000${haberdasherPathPrefix}MakeHat`, { 141 | body: JSON.stringify({ 142 | inches: 42, 143 | }), 144 | headers: { 145 | 'Content-Type': 'image/png', 146 | }, 147 | resolveWithFullResponse: true, 148 | simple: false, 149 | method: 'POST', 150 | }); 151 | 152 | expect(response.statusCode).toEqual(404); 153 | expect(response.headers['content-type']).toEqual('application/json'); 154 | const body = JSON.parse(response.body); 155 | expect(body).toEqual({ 156 | code: 'bad_route', 157 | msg: 'unexpected Content-Type: image/png', 158 | }); 159 | }); 160 | 161 | test('Non POST verb returns 404', async () => { 162 | server.handler = createHaberdasherHandler({ 163 | makeHat(size: Example.Size) { 164 | return { 165 | color: 'red', 166 | name: 'fancy hat', 167 | size: size.inches, 168 | }; 169 | }, 170 | }); 171 | 172 | const response = await request(`http://localhost:8000${haberdasherPathPrefix}MakeHat`, { 173 | body: JSON.stringify({ 174 | inches: 42, 175 | }), 176 | headers: { 177 | 'Content-Type': 'application/json', 178 | }, 179 | resolveWithFullResponse: true, 180 | simple: false, 181 | method: 'GET', 182 | }); 183 | 184 | expect(response.statusCode).toEqual(404); 185 | expect(response.headers['content-type']).toEqual('application/json'); 186 | const body = JSON.parse(response.body); 187 | expect(body).toEqual({ 188 | code: 'bad_route', 189 | msg: 'unsupported method GET (only POST is allowed)', 190 | }); 191 | }); 192 | 193 | test('exposing the path prefix', async () => { 194 | expect(haberdasherPathPrefix).toBe('/twirp/twitch.twirp.example.Haberdasher/'); 195 | }); 196 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module provides Twirp errors according to the Twirp spec. 3 | */ 4 | 5 | export class TwirpError extends Error { 6 | public statusCode = 500; 7 | public message: string = this.message; 8 | public name = 'internal'; 9 | public isTwirpError: true = true; 10 | } 11 | 12 | enum TwirpErrorCode { 13 | // Canceled indicates the operation was cancelled (typically by the caller). 14 | Canceled = 'canceled', 15 | 16 | // Unknown error. For example when handling errors raised by APIs that do not 17 | // return enough error information. 18 | Unknown = 'unknown', 19 | 20 | // InvalidArgument indicates client specified an invalid argument. It 21 | // indicates arguments that are problematic regardless of the state of the 22 | // system (i.e. a malformed file name, required argument, number out of range, 23 | // etc.). 24 | InvalidArgument = 'invalid_argument', 25 | 26 | // DeadlineExceeded means operation expired before completion. For operations 27 | // that change the state of the system, this error may be returned even if the 28 | // operation has completed successfully (timeout). 29 | DeadlineExceeded = 'deadline_exceeded', 30 | 31 | // NotFound means some requested entity was not found. 32 | NotFound = 'not_found', 33 | 34 | // BadRoute means that the requested URL path wasn't routable to a Twirp 35 | // service and method. This is returned by the generated server, and usually 36 | // shouldn't be returned by applications. Instead, applications should use 37 | // NotFound or Unimplemented. 38 | BadRoute = 'bad_route', 39 | 40 | // AlreadyExists means an attempt to create an entity failed because one 41 | // already exists. 42 | AlreadyExists = 'already_exists', 43 | 44 | // PermissionDenied indicates the caller does not have permission to execute 45 | // the specified operation. It must not be used if the caller cannot be 46 | // identified (Unauthenticated). 47 | PermissionDenied = 'permission_denied', 48 | 49 | // Unauthenticated indicates the request does not have valid authentication 50 | // credentials for the operation. 51 | Unauthenticated = 'unauthenticated', 52 | 53 | // ResourceExhausted indicates some resource has been exhausted, perhaps a 54 | // per-user quota, or perhaps the entire file system is out of space. 55 | ResourceExhausted = 'resource_exhausted', 56 | 57 | // FailedPrecondition indicates operation was rejected because the system is 58 | // not in a state required for the operation's execution. For example, doing 59 | // an rmdir operation on a directory that is non-empty, or on a non-directory 60 | // object, or when having conflicting read-modify-write on the same resource. 61 | FailedPrecondition = 'failed_precondition', 62 | 63 | // Aborted indicates the operation was aborted, typically due to a concurrency 64 | // issue like sequencer check failures, transaction aborts, etc. 65 | Aborted = 'aborted', 66 | 67 | // OutOfRange means operation was attempted past the valid range. For example, 68 | // seeking or reading past end of a paginated collection. 69 | // 70 | // Unlike InvalidArgument, this error indicates a problem that may be fixed if 71 | // the system state changes (i.e. adding more items to the collection). 72 | // 73 | // There is a fair bit of overlap between FailedPrecondition and OutOfRange. 74 | // We recommend using OutOfRange (the more specific error) when it applies so 75 | // that callers who are iterating through a space can easily look for an 76 | // OutOfRange error to detect when they are done. 77 | OutOfRange = 'out_of_range', 78 | 79 | // Unimplemented indicates operation is not implemented or not 80 | // supported/enabled in this service. 81 | Unimplemented = 'unimplemented', 82 | 83 | // Internal errors. When some invariants expected by the underlying system 84 | // have been broken. In other words, something bad happened in the library or 85 | // backend service. Do not confuse with HTTP Internal Server Error; an 86 | // Internal error could also happen on the client code, i.e. when parsing a 87 | // server response. 88 | Internal = 'internal', 89 | 90 | // Unavailable indicates the service is currently unavailable. This is a most 91 | // likely a transient condition and may be corrected by retrying with a 92 | // backoff. 93 | Unavailable = 'unavailable', 94 | 95 | // DataLoss indicates unrecoverable data loss or corruption. 96 | DataLoss = 'data_loss', 97 | } 98 | 99 | // NotFoundError for the common NotFound error. 100 | export class NotFoundError extends TwirpError { 101 | statusCode = 404; 102 | name = TwirpErrorCode.NotFound; 103 | } 104 | 105 | // InvalidArgumentError constructor for the common InvalidArgument error. Can be 106 | // used when an argument has invalid format, is a number out of range, is a bad 107 | // option, etc). 108 | export class InvalidArgumentError extends TwirpError { 109 | statusCode = 400; 110 | name = TwirpErrorCode.InvalidArgument; 111 | } 112 | 113 | // RequiredArgumentError is a more specific constructor for InvalidArgument 114 | // error. Should be used when the argument is required (expected to have a 115 | // non-zero value). 116 | export class RequiredArgumentError extends TwirpError { 117 | statusCode = 400; 118 | name = TwirpErrorCode.InvalidArgument; 119 | constructor(argumentName: string) { 120 | super(`${argumentName} is required`); 121 | } 122 | } 123 | 124 | // InternalError constructor for the common Internal error. Should be used to 125 | // specify that something bad or unexpected happened. 126 | export class InternalServerError extends TwirpError { 127 | statusCode = 500; 128 | name = TwirpErrorCode.Internal; 129 | } 130 | 131 | // badRouteError is used when the twirp server cannot route a request`) 132 | export class BadRouteError extends TwirpError { 133 | statusCode = 404; 134 | name = TwirpErrorCode.BadRoute; 135 | } 136 | 137 | // twirpErrorFromIntermediary maps HTTP errors from non-twirp sources to twirp errors. 138 | // The mapping is similar to gRPC: https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md. 139 | export function twirpErrorFromIntermediary( 140 | status: number, 141 | // msg: string, 142 | // bodyOrLocation: string, 143 | ): TwirpErrorCode { 144 | let code = TwirpErrorCode.Unknown; 145 | if (status >= 300 && status <= 399) { 146 | code = TwirpErrorCode.Internal; 147 | } else { 148 | switch (status) { 149 | case 400: // Bad Request 150 | code = TwirpErrorCode.Internal; 151 | break; 152 | case 401: // Unauthorized 153 | code = TwirpErrorCode.Unauthenticated; 154 | break; 155 | case 403: // Forbidden 156 | code = TwirpErrorCode.PermissionDenied; 157 | break; 158 | case 404: // Not Found 159 | code = TwirpErrorCode.BadRoute; 160 | break; 161 | case 429: // Too Many Requests 162 | case 502: // Bad Gateway 163 | case 503: // Service Unavailable 164 | case 504: // Gateway Timeout 165 | code = TwirpErrorCode.Unavailable; 166 | break; 167 | default: 168 | // All other codes 169 | code = TwirpErrorCode.Unknown; 170 | } 171 | } 172 | 173 | return code; 174 | } 175 | --------------------------------------------------------------------------------