├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .nvmrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── buf.yaml ├── client ├── grpc-web-fake-transport │ ├── README.md │ ├── jest.config.js │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── index.spec.ts │ │ └── index.ts │ └── tsconfig.json ├── grpc-web-node-http-transport │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── grpc-web-react-example │ ├── README.md │ ├── go │ │ ├── _proto │ │ │ └── examplecom │ │ │ │ └── library │ │ │ │ └── book_service.pb.go │ │ └── exampleserver │ │ │ └── exampleserver.go │ ├── package-lock.json │ ├── package.json │ ├── proto │ │ └── examplecom │ │ │ └── library │ │ │ └── book_service.proto │ ├── protogen.sh │ └── ts │ │ ├── _proto │ │ └── examplecom │ │ │ └── library │ │ │ ├── book_service_pb.d.ts │ │ │ ├── book_service_pb.js │ │ │ ├── book_service_pb_service.d.ts │ │ │ ├── book_service_pb_service.js │ │ │ └── book_service_pb_service.ts │ │ ├── index.html │ │ ├── src │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── webpack.config.js ├── grpc-web-react-native-transport │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json └── grpc-web │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── docs │ ├── client.md │ ├── code-generation.md │ ├── concepts.md │ ├── invoke.md │ ├── transport.md │ ├── unary.md │ └── websocket.md │ ├── package-lock.json │ ├── package.json │ ├── release.sh │ ├── src │ ├── ChunkParser.ts │ ├── Code.ts │ ├── client.ts │ ├── debug.ts │ ├── detach.ts │ ├── index.ts │ ├── invoke.ts │ ├── message.ts │ ├── metadata.ts │ ├── service.ts │ ├── transports │ │ ├── Transport.ts │ │ ├── http │ │ │ ├── fetch.ts │ │ │ ├── http.ts │ │ │ ├── xhr.ts │ │ │ └── xhrUtil.ts │ │ └── websocket │ │ │ └── websocket.ts │ ├── unary.ts │ └── util.ts │ ├── tsconfig.json │ └── webpack.config.js ├── go.mod ├── go.sum ├── go ├── README.md ├── generate-docs.sh ├── grpcweb │ ├── DOC.md │ ├── README.md │ ├── doc.go │ ├── grpc_web_response.go │ ├── header.go │ ├── health.go │ ├── health_test.go │ ├── helpers.go │ ├── helpers_internal_test.go │ ├── helpers_test.go │ ├── options.go │ ├── trailer.go │ ├── trailer_test.go │ ├── websocket_wrapper.go │ ├── wrapper.go │ └── wrapper_test.go ├── grpcwebproxy │ ├── .gitignore │ ├── README.md │ ├── backend.go │ ├── main.go │ └── server_tls.go └── lint.sh ├── install-buf.sh ├── install-protoc.sh ├── integration_test ├── .gitignore ├── README.md ├── browsers.ts ├── custom-karma-driver.ts ├── go │ ├── _proto │ │ └── improbable │ │ │ └── grpcweb │ │ │ └── test │ │ │ └── test.pb.go │ └── testserver │ │ └── testserver.go ├── hosts-config.ts ├── karma.conf.ts ├── package-lock.json ├── package.json ├── proto │ ├── buf.gen.yaml │ └── improbable │ │ └── grpcweb │ │ └── test │ │ └── test.proto ├── run-with-testserver.sh ├── run-with-tunnel.sh ├── start-testserver.sh ├── test-ci.sh ├── test.sh └── ts │ ├── .babelrc │ ├── _proto │ └── improbable │ │ └── grpcweb │ │ └── test │ │ ├── test_pb.d.ts │ │ ├── test_pb.js │ │ ├── test_pb_service.d.ts │ │ └── test_pb_service.js │ ├── node-src │ ├── node.spec.ts │ └── tsconfig.json │ ├── src │ ├── ChunkParser.spec.ts │ ├── cancellation.spec.ts │ ├── client.spec.ts │ ├── client.websocket.spec.ts │ ├── invoke.spec.ts │ ├── spec.ts │ ├── testRpcCombinations.ts │ ├── unary.spec.ts │ └── util.ts │ ├── tsconfig.json │ └── webpack.config.ts ├── lerna.json ├── lint-all.sh ├── misc ├── gen_cert.sh ├── localhost.conf ├── localhost.crt ├── localhost.key ├── localhostCA.conf └── localhostCA.pem ├── package-lock.json ├── package.json ├── publish-release.sh ├── test-all.sh ├── tools.go └── tslint.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | executors: 4 | test-executor: 5 | docker: 6 | - image: circleci/golang:1.16.2 7 | auth: 8 | username: mydockerhub-user 9 | password: $DOCKERHUB_PASSWORD 10 | environment: 11 | BUF_VER: 0.40.0 12 | PROTOC_VER: 3.15.6 13 | working_directory: /go/src/github.com/improbable-eng/grpc-web 14 | 15 | jobs: 16 | # This first job in the workflow optimises test time by combining the following: 17 | # * Linting 18 | # * Golang unit tests 19 | # * TypeScript unit tests 20 | # * Builds integration testserver binary 21 | # * Builds integration test Javascript bundles 22 | initial-unit-test-lint-prebuild: 23 | executor: test-executor 24 | steps: 25 | - checkout 26 | - run: sudo apt-get install unzip 27 | - run: . ./install-buf.sh 28 | - run: . ./install-protoc.sh 29 | - run: echo 'export GOBIN=/go/bin' >> $BASH_ENV 30 | - run: go get golang.org/x/tools/cmd/goimports 31 | - run: go get github.com/robertkrimen/godocdown/godocdown 32 | - run: go mod tidy # removes dependencies added by tool installations 33 | - run: go install github.com/golang/protobuf/protoc-gen-go 34 | - run: 35 | command: | 36 | set +e 37 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.33.5/install.sh | bash 38 | export NVM_DIR="/home/circleci/.nvm" 39 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 40 | nvm install 41 | echo 'export NVM_DIR=$HOME/.nvm' >> $BASH_ENV 42 | echo 'source $NVM_DIR/nvm.sh' >> $BASH_ENV 43 | - run: npm install 44 | # Verify test files are correctly generated 45 | - run: cd integration_test && npm run build:proto && git diff --exit-code 46 | # Lint 47 | - run: ./lint-all.sh 48 | # Unit Tests 49 | - run: ./test-all.sh 50 | # Build server binary and Javascript bundles to be used across all subsequent browser tests 51 | - run: 52 | name: Pre-Build Integration Test 53 | command: npm run build:integration 54 | # Persisting the workspace allows the built assets to be used across the fan-out tests in the subsequent step 55 | - persist_to_workspace: 56 | root: /go/src/github.com/improbable-eng/grpc-web 57 | paths: 58 | - integration_test 59 | - client 60 | browser-tests: 61 | parameters: 62 | browser-params: 63 | type: string 64 | executor: test-executor 65 | steps: 66 | - checkout 67 | # Attaches the workspace from the previous step to retrieve the built assets for the tests 68 | - attach_workspace: 69 | at: /go/src/github.com/improbable-eng/grpc-web 70 | - run: sudo apt-get install moreutils 71 | # The two following hosts must match those in integration_test/hosts-config.ts 72 | - run: echo 127.0.0.1 testhost | sudo tee -a /etc/hosts 73 | - run: echo 127.0.0.1 corshost | sudo tee -a /etc/hosts 74 | - run: 75 | command: | 76 | set +e 77 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.33.5/install.sh | bash 78 | export NVM_DIR="/home/circleci/.nvm" 79 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 80 | nvm install 81 | echo 'export NVM_DIR=$HOME/.nvm' >> $BASH_ENV 82 | echo 'source $NVM_DIR/nvm.sh' >> $BASH_ENV 83 | - run: << parameters.browser-params >> npm run test:integration-browsers:ci 84 | - store_test_results: 85 | path: ./integration_test/test-results 86 | - store_artifacts: 87 | path: ./integration_test/sauce-connect-proxy/logs 88 | workflows: 89 | version: 2 90 | test-workflow: 91 | jobs: 92 | - initial-unit-test-lint-prebuild 93 | # Split browser test matrix into separate jobs to keep parallelism 94 | # to a maximum of 5 jobs, the maximum number of parallel jobs 95 | # our SauceLabs configuration allows. 96 | - browser-tests: 97 | matrix: 98 | alias: browser-tests-first 99 | parameters: 100 | browser-params: 101 | - BROWSER=firefox86_win 102 | - BROWSER=firefox39_win DISABLE_WEBSOCKET_TESTS=true 103 | - BROWSER=firefox38_win DISABLE_WEBSOCKET_TESTS=true 104 | - BROWSER=chrome_89 105 | - BROWSER=chrome_52 106 | requires: 107 | - initial-unit-test-lint-prebuild 108 | - browser-tests: 109 | matrix: 110 | alias: browser-tests-second 111 | parameters: 112 | browser-params: 113 | - BROWSER=chrome_43 114 | - BROWSER=chrome_42 115 | - BROWSER=chrome_41 116 | - BROWSER=edge88_win 117 | - BROWSER=edge16_win 118 | requires: 119 | - browser-tests-first 120 | - browser-tests: 121 | matrix: 122 | alias: browser-tests-third 123 | parameters: 124 | browser-params: 125 | - BROWSER=edge14_win 126 | - BROWSER=edge13_win 127 | - BROWSER=safari14 SC_SSL_BUMPING=true DISABLE_WEBSOCKET_TESTS=true 128 | - BROWSER=safari13_1 SC_SSL_BUMPING=true DISABLE_WEBSOCKET_TESTS=true 129 | - BROWSER=safari12_1 SC_SSL_BUMPING=true DISABLE_WEBSOCKET_TESTS=true 130 | requires: 131 | - browser-tests-second 132 | - browser-tests: 133 | matrix: 134 | alias: browser-tests-fourth 135 | parameters: 136 | browser-params: 137 | - BROWSER=safari11_1 SC_SSL_BUMPING=true DISABLE_WEBSOCKET_TESTS=true 138 | - BROWSER=safari10_1 SC_SSL_BUMPING=true DISABLE_WEBSOCKET_TESTS=true 139 | - BROWSER=safari9_1 SC_SSL_BUMPING=true DISABLE_WEBSOCKET_TESTS=true 140 | - BROWSER=safari8 SC_SSL_BUMPING=true DISABLE_WEBSOCKET_TESTS=true 141 | - BROWSER=ie11_win DISABLE_WEBSOCKET_TESTS=true 142 | requires: 143 | - browser-tests-third 144 | - browser-tests: 145 | matrix: 146 | parameters: 147 | browser-params: 148 | - BROWSER=nodejs 149 | requires: 150 | - browser-tests-fourth 151 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.sh] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | 12 | [*.js] 13 | indent_style = space 14 | indent_size = 2 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | end_of_line = lf 19 | # editorconfig-tools is unable to ignore longs strings or urls 20 | max_line_length = null 21 | 22 | # Override for Makefile 23 | [{Makefile, makefile, GNUmakefile}] 24 | indent_style = tab 25 | indent_size = 4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea 8 | .idea/workspace.xml 9 | .idea/tasks.xml 10 | .idea/dictionaries 11 | .idea/vcs.xml 12 | .idea/jsLibraryMappings.xml 13 | 14 | # Sensitive or high-churn files: 15 | .idea/dataSources.ids 16 | .idea/dataSources.xml 17 | .idea/dataSources.local.xml 18 | .idea/sqlDataSources.xml 19 | .idea/dynamic.xml 20 | .idea/uiDesigner.xml 21 | 22 | # Gradle: 23 | .idea/gradle.xml 24 | .idea/libraries 25 | 26 | # Mongo Explorer plugin: 27 | .idea/mongoSettings.xml 28 | 29 | ## File-based project format: 30 | *.iws 31 | 32 | ## Plugin-specific files: 33 | 34 | # IntelliJ 35 | /out/ 36 | 37 | # mpeltonen/sbt-idea plugin 38 | .idea_modules/ 39 | 40 | # JIRA plugin 41 | atlassian-ide-plugin.xml 42 | 43 | # Crashlytics plugin (for Android Studio and IntelliJ) 44 | com_crashlytics_export_strings.xml 45 | crashlytics.properties 46 | crashlytics-build.properties 47 | fabric.properties 48 | ### Go template 49 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 50 | *.o 51 | *.a 52 | *.so 53 | 54 | # Folders 55 | _obj 56 | _test 57 | 58 | # Architecture specific extensions/prefixes 59 | *.[568vq] 60 | [568vq].out 61 | 62 | *.cgo1.go 63 | *.cgo2.c 64 | _cgo_defun.c 65 | _cgo_gotypes.go 66 | _cgo_export.* 67 | 68 | _testmain.go 69 | 70 | *.exe 71 | *.test 72 | *.prof 73 | ### Python template 74 | # Byte-compiled / optimized / DLL files 75 | __pycache__/ 76 | *.py[cod] 77 | *$py.class 78 | 79 | # C extensions 80 | *.so 81 | 82 | # Distribution / packaging 83 | .Python 84 | env/ 85 | build/ 86 | develop-eggs/ 87 | dist/ 88 | downloads/ 89 | eggs/ 90 | .eggs/ 91 | lib/ 92 | lib64/ 93 | parts/ 94 | sdist/ 95 | var/ 96 | *.egg-info/ 97 | .installed.cfg 98 | *.egg 99 | 100 | # PyInstaller 101 | # Usually these files are written by a python script from a template 102 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 103 | *.manifest 104 | *.spec 105 | 106 | # Installer logs 107 | pip-log.txt 108 | pip-delete-this-directory.txt 109 | 110 | # Unit test / coverage reports 111 | htmlcov/ 112 | .tox/ 113 | .coverage 114 | .coverage.* 115 | .cache 116 | nosetests.xml 117 | coverage.xml 118 | *,cover 119 | .hypothesis/ 120 | 121 | # Translations 122 | *.mo 123 | *.pot 124 | 125 | # Django stuff: 126 | *.log 127 | local_settings.py 128 | 129 | # Flask stuff: 130 | instance/ 131 | .webassets-cache 132 | 133 | # Scrapy stuff: 134 | .scrapy 135 | 136 | # Sphinx documentation 137 | docs/_build/ 138 | 139 | # PyBuilder 140 | target/ 141 | 142 | # IPython Notebook 143 | .ipynb_checkpoints 144 | 145 | # pyenv 146 | .python-version 147 | 148 | # celery beat schedule file 149 | celerybeat-schedule 150 | 151 | # dotenv 152 | .env 153 | 154 | # virtualenv 155 | venv/ 156 | ENV/ 157 | 158 | # Spyder project settings 159 | .spyderproject 160 | 161 | # Rope project settings 162 | .ropeproject 163 | 164 | # localhost cert authority intermediate files 165 | misc/localhost.csr 166 | misc/localhostCA.key 167 | misc/localhostCA.srl 168 | 169 | node_modules 170 | vendor 171 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 15.7.0 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Developer Tool Prerequisites 2 | * [go](https://golang.org/doc/install) - The Go programming language 3 | * [nvm](https://github.com/creationix/nvm#installation) - Node Version Manager (for installing NodeJS and NPM) 4 | 5 | ## Performing a Fresh Checkout 6 | The following steps guide you through a fresh checkout 7 | 8 | ``` 9 | # Create a workspace 10 | cd ~/Projects/ # or wherever you want your checkout to live 11 | 12 | # Checkout project sources 13 | git clone git@github.com:improbable-eng/grpc-web.git 14 | cd grpc-web 15 | 16 | # Install NodeJS dependencies 17 | nvm use 18 | npm install 19 | ``` 20 | 21 | Note you will also need to [install buf](https://github.com/bufbuild/buf) and add it to your `PATH` environment variable if you wish to re-generate the integration test proto files. 22 | 23 | ## Testing Prerequisites 24 | Before you run the tests for the first time, please follow these steps: 25 | 26 | ### Installing the Local Certificate 27 | In order to run the Karma (Integration) tests, you will need to add the certificate found in `misc/localhostCA.pem` to your system's list of trusted certificates. This will vary from operating system to operating system. 28 | 29 | ### macOS 30 | 1. Open Keychain Access 31 | 2. Select 'System' from the list of Keychains 32 | 3. From the `File` menu, select `Import Items` 33 | 4. Select `misc/localhost.crt` 34 | 5. Double click on the new `GRPC Web example dev server` certificate 35 | 6. Expand the `Trust' section 36 | 7. Change the `When using this certificate` option to `Always Trust` 37 | 8. Close the certificate details pop-up. 38 | 39 | Repeat the above process for `misc/localhostCA.pem`. 40 | 41 | ### Setting the required hostnames 42 | Add the following entries to your system's `hosts` file: 43 | 44 | ``` 45 | # grpc-web 46 | 127.0.0.1 testhost 47 | 127.0.0.1 corshost 48 | ``` 49 | 50 | ## Running the Tests 51 | These steps assume you have performed all the necessary testing prerequisites. Also note that running all the tests will require you to open a web-browser on your machine. 52 | 53 | To start the test suite, run: 54 | 55 | ``` 56 | cd src/github.com/improbable-eng/grpc-web 57 | npm test 58 | ``` 59 | 60 | At some point during the test run, execution will pause and the following line will be printed: 61 | 62 | ``` 63 | INFO [karma]: Karma v3.0.0 server started at https://0.0.0.0:9876/ 64 | ``` 65 | 66 | This is your prompt to open a web browser on https://localhost:9876 at which point the tests will continue to run. 67 | 68 | ## Creating a Release 69 | 1. From a fresh checkout of master, create a release branch, ie: `feature/prepare-x.y.z-release` 70 | 2. Update `CHANGELOG.md` by comparing commits to master since the last Github Release 71 | 3. Raise a pull request for your changes, have it reviewed and landed into master. 72 | 4. Switch your local checkout back to the master branch, pull your merged changes and run `./publish-release.sh`. 73 | 5. Create the ARM binaries and attach them to the Github release. 74 | -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1beta1 2 | build: 3 | roots: 4 | - integration_test/proto 5 | lint: 6 | use: 7 | - DEFAULT 8 | ignore_only: 9 | ENUM_VALUE_PREFIX: 10 | - improbable/grpcweb/test/test.proto 11 | ENUM_ZERO_VALUE_SUFFIX: 12 | - improbable/grpcweb/test/test.proto 13 | FIELD_LOWER_SNAKE_CASE: 14 | - improbable/grpcweb/test/test.proto 15 | PACKAGE_VERSION_SUFFIX: 16 | - improbable/grpcweb/test/test.proto 17 | RPC_REQUEST_RESPONSE_UNIQUE: 18 | - improbable/grpcweb/test/test.proto 19 | RPC_REQUEST_STANDARD_NAME: 20 | - improbable/grpcweb/test/test.proto 21 | RPC_RESPONSE_STANDARD_NAME: 22 | - improbable/grpcweb/test/test.proto 23 | breaking: 24 | use: 25 | - FILE 26 | -------------------------------------------------------------------------------- /client/grpc-web-fake-transport/README.md: -------------------------------------------------------------------------------- 1 | # @improbable-eng/grpc-web-fake-transport 2 | > Fake Transport for use with [@improbable-eng/grpc-web](https://github.com/improbable-eng/grpc-web). 3 | 4 | ## Usage 5 | 6 | `FakeTransportBuilder` builds a `Transport` that can be configured to send preset headers, messages, and trailers for testing. 7 | 8 | By default the `Transport` that `FakeTranportBuilder.build()` generates will trigger all of the specified response behaviours (`.withHeaders(metadata)`, `.withMessages([msgOne, msgTwo])`, `.withTrailers(metadata)`) when the client has finished sending. 9 | 10 | This is usually the desired flow as all of the response behaviour is triggered only after the client has finished sending as would most commonly occur in production usage. 11 | 12 | However, in the case of bi-directional or other complex usage it can be helpful to use `.withManualTrigger()` to disable automatic sending of messages or headers/trailers and trigger the sending manually using `sendHeaders()`, `sendMessages()` and `sendTrailers()`. 13 | 14 | ### With Service Stubs generated by ts-protoc-gen 15 | ```typescript 16 | import { FakeTransportBuilder } from '@improbable-eng/grpc-web-fake-transport'; 17 | 18 | const fakeTransport = new FakeTransportBuilder() 19 | .withMessages([ new PingResponse() ]) 20 | .build(); 21 | 22 | const client = new PingServiceClient("https://example.com", { 23 | transport: fakeTransport 24 | }); 25 | 26 | client.DoPing(/* ... */); 27 | ``` 28 | 29 | ### With grpc-web 30 | ```typescript 31 | import { grpc } from '@improbable-eng/grpc-web'; 32 | import { FakeTransportBuilder } from '@improbable-eng/grpc-web-fake-transport'; 33 | 34 | const fakeTransport = new FakeTransportBuilder() 35 | .withMessages([ new PingResponse() ]) 36 | .build(); 37 | 38 | grpc.invoke(PingService.DoPing, { 39 | host: "https://example.com", 40 | transport: fakeTransport, 41 | /* ... */ 42 | }); 43 | ``` 44 | 45 | ### With `withManualTrigger()` 46 | ```typescript 47 | import { grpc } from '@improbable-eng/grpc-web'; 48 | import { FakeTransportBuilder } from '@improbable-eng/grpc-web-fake-transport'; 49 | 50 | const fakeTransport = new FakeTransportBuilder() 51 | .withManualTrigger() 52 | .withHeaders(new grpc.Metadata({ headerKey: "value" })) 53 | .withMessages([ new PingResponse() ]) 54 | .withTrailers(new grpc.Metadata({ trailerKey: "value" })) 55 | .build(); 56 | 57 | grpc.invoke(PingService.DoPing, { 58 | host: "https://example.com", 59 | transport: fakeTransport, 60 | /* ... */ 61 | }); 62 | 63 | // Manually trigger the response behaviours 64 | fakeTransport.sendHeaders(); 65 | fakeTransport.sendMessages(); 66 | fakeTransport.sendTrailers(); 67 | ``` 68 | 69 | Alternatively replace the Default Transport when initialising your tests: 70 | ```typescript 71 | import { grpc } from "@improbable-eng/grpc-web"; 72 | import { NodeHttpTransport } from "@improbable-eng/grpc-web-fake-transport"; 73 | 74 | // Do this first, before you make any grpc requests! 75 | grpc.setDefaultTransport(fakeTransport); 76 | ``` 77 | -------------------------------------------------------------------------------- /client/grpc-web-fake-transport/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node' 4 | }; 5 | -------------------------------------------------------------------------------- /client/grpc-web-fake-transport/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@improbable-eng/grpc-web-fake-transport", 3 | "version": "0.15.0", 4 | "description": "Fake Transport for use with @improbable-eng/grpc-web", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "clean": "rm -rf lib", 8 | "postbootstrap": "npm run build", 9 | "build": "tsc", 10 | "test": "jest" 11 | }, 12 | "publishConfig": { 13 | "access": "public" 14 | }, 15 | "author": "", 16 | "license": "Apache-2.0", 17 | "peerDependencies": { 18 | "@improbable-eng/grpc-web": ">=0.13.0", 19 | "google-protobuf": ">=3.14.0" 20 | }, 21 | "devDependencies": { 22 | "@improbable-eng/grpc-web": "^0.13.0", 23 | "@types/google-protobuf": "^3.7.4", 24 | "@types/jest": "^26.0.20", 25 | "@types/lodash.assignin": "^4.2.6", 26 | "@types/node": "^14.14.22", 27 | "google-protobuf": "^3.7.1", 28 | "jest": "^26.6.3", 29 | "ts-jest": "^26.5.0", 30 | "typescript": "^4.1.3" 31 | }, 32 | "dependencies": { 33 | "lodash.assignin": "^4.2.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/grpc-web-fake-transport/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "sourceMap": true, 5 | "declaration": true, 6 | "declarationDir": "lib", 7 | "target": "es5", 8 | "removeComments": true, 9 | "noImplicitReturns": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "strictNullChecks": true, 14 | "stripInternal": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "outDir": "lib", 17 | "noEmitOnError": true 18 | }, 19 | "types": [ 20 | "node" 21 | ], 22 | "include": [ 23 | "src" 24 | ], 25 | "exclude": [ 26 | "node_modules", 27 | "**/*.spec.ts" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /client/grpc-web-node-http-transport/README.md: -------------------------------------------------------------------------------- 1 | # @improbable-eng/grpc-web-node-http-transport 2 | Node HTTP Transport for use with [@improbable-eng/grpc-web](https://github.com/improbable-eng/grpc-web) 3 | 4 | ## Usage 5 | When making a gRPC request, specify this transport: 6 | 7 | ```typescript 8 | import { grpc } from '@improbable-eng/grpc-web'; 9 | import { NodeHttpTransport } from '@improbable-eng/grpc-web-node-http-transport'; 10 | 11 | grpc.invoke(MyService.DoQuery, { 12 | host: "https://example.com", 13 | transport: NodeHttpTransport(), 14 | /* ... */ 15 | }) 16 | ``` 17 | 18 | Alternatively specify the Default Transport when your server/application bootstraps: 19 | ```typescript 20 | import { grpc } from "@improbable-eng/grpc-web"; 21 | import { NodeHttpTransport } from "@improbable-eng/grpc-web-node-http-transport"; 22 | 23 | // Do this first, before you make any grpc requests! 24 | grpc.setDefaultTransport(NodeHttpTransport()); 25 | ``` -------------------------------------------------------------------------------- /client/grpc-web-node-http-transport/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@improbable-eng/grpc-web-node-http-transport", 3 | "version": "0.15.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@improbable-eng/grpc-web-node-http-transport", 9 | "version": "0.15.0", 10 | "license": "Apache-2.0", 11 | "devDependencies": { 12 | "@improbable-eng/grpc-web": "^0.13.0", 13 | "@types/node": "^14.14.22", 14 | "google-protobuf": "^3.14.0", 15 | "typescript": "^4.1.3" 16 | }, 17 | "peerDependencies": { 18 | "@improbable-eng/grpc-web": ">=0.13.0" 19 | } 20 | }, 21 | "node_modules/@improbable-eng/grpc-web": { 22 | "version": "0.13.0", 23 | "resolved": "https://registry.npmjs.org/@improbable-eng/grpc-web/-/grpc-web-0.13.0.tgz", 24 | "integrity": "sha512-vaxxT+Qwb7GPqDQrBV4vAAfH0HywgOLw6xGIKXd9Q8hcV63CQhmS3p4+pZ9/wVvt4Ph3ZDK9fdC983b9aGMUFg==", 25 | "dev": true, 26 | "dependencies": { 27 | "browser-headers": "^0.4.0" 28 | }, 29 | "peerDependencies": { 30 | "google-protobuf": "^3.2.0" 31 | } 32 | }, 33 | "node_modules/@types/node": { 34 | "version": "14.14.22", 35 | "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz", 36 | "integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw==", 37 | "dev": true 38 | }, 39 | "node_modules/browser-headers": { 40 | "version": "0.4.1", 41 | "resolved": "https://registry.npmjs.org/browser-headers/-/browser-headers-0.4.1.tgz", 42 | "integrity": "sha512-CA9hsySZVo9371qEHjHZtYxV2cFtVj5Wj/ZHi8ooEsrtm4vOnl9Y9HmyYWk9q+05d7K3rdoAE0j3MVEFVvtQtg==", 43 | "dev": true 44 | }, 45 | "node_modules/google-protobuf": { 46 | "version": "3.14.0", 47 | "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.14.0.tgz", 48 | "integrity": "sha512-bwa8dBuMpOxg7COyqkW6muQuvNnWgVN8TX/epDRGW5m0jcrmq2QJyCyiV8ZE2/6LaIIqJtiv9bYokFhfpy/o6w==", 49 | "dev": true 50 | }, 51 | "node_modules/typescript": { 52 | "version": "4.1.3", 53 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", 54 | "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", 55 | "dev": true, 56 | "bin": { 57 | "tsc": "bin/tsc", 58 | "tsserver": "bin/tsserver" 59 | }, 60 | "engines": { 61 | "node": ">=4.2.0" 62 | } 63 | } 64 | }, 65 | "dependencies": { 66 | "@improbable-eng/grpc-web": { 67 | "version": "0.13.0", 68 | "resolved": "https://registry.npmjs.org/@improbable-eng/grpc-web/-/grpc-web-0.13.0.tgz", 69 | "integrity": "sha512-vaxxT+Qwb7GPqDQrBV4vAAfH0HywgOLw6xGIKXd9Q8hcV63CQhmS3p4+pZ9/wVvt4Ph3ZDK9fdC983b9aGMUFg==", 70 | "dev": true, 71 | "requires": { 72 | "browser-headers": "^0.4.0" 73 | } 74 | }, 75 | "@types/node": { 76 | "version": "14.14.22", 77 | "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz", 78 | "integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw==", 79 | "dev": true 80 | }, 81 | "browser-headers": { 82 | "version": "0.4.1", 83 | "resolved": "https://registry.npmjs.org/browser-headers/-/browser-headers-0.4.1.tgz", 84 | "integrity": "sha512-CA9hsySZVo9371qEHjHZtYxV2cFtVj5Wj/ZHi8ooEsrtm4vOnl9Y9HmyYWk9q+05d7K3rdoAE0j3MVEFVvtQtg==", 85 | "dev": true 86 | }, 87 | "google-protobuf": { 88 | "version": "3.14.0", 89 | "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.14.0.tgz", 90 | "integrity": "sha512-bwa8dBuMpOxg7COyqkW6muQuvNnWgVN8TX/epDRGW5m0jcrmq2QJyCyiV8ZE2/6LaIIqJtiv9bYokFhfpy/o6w==", 91 | "dev": true 92 | }, 93 | "typescript": { 94 | "version": "4.1.3", 95 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", 96 | "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", 97 | "dev": true 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /client/grpc-web-node-http-transport/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@improbable-eng/grpc-web-node-http-transport", 3 | "version": "0.15.0", 4 | "description": "Node HTTP Transport for use with @improbable-eng/grpc-web", 5 | "main": "lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "github.com/improbable-eng/grpc-web" 9 | }, 10 | "scripts": { 11 | "clean": "rm -rf lib", 12 | "postbootstrap": "npm run build", 13 | "build": "tsc" 14 | }, 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "author": "Improbable", 19 | "license": "Apache-2.0", 20 | "peerDependencies": { 21 | "@improbable-eng/grpc-web": ">=0.13.0" 22 | }, 23 | "devDependencies": { 24 | "@improbable-eng/grpc-web": "^0.13.0", 25 | "@types/node": "^14.14.22", 26 | "google-protobuf": "^3.14.0", 27 | "typescript": "^4.1.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/grpc-web-node-http-transport/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | import * as https from "https"; 3 | import * as url from "url"; 4 | import { grpc } from "@improbable-eng/grpc-web"; 5 | 6 | export function NodeHttpTransport(httpsOptions?: https.RequestOptions): grpc.TransportFactory { 7 | return (opts: grpc.TransportOptions) => { 8 | return new NodeHttp(opts, httpsOptions); 9 | }; 10 | } 11 | 12 | class NodeHttp implements grpc.Transport { 13 | options: grpc.TransportOptions; 14 | request: http.ClientRequest; 15 | 16 | constructor(transportOptions: grpc.TransportOptions, readonly httpsOptions?: https.RequestOptions) { 17 | this.options = transportOptions; 18 | } 19 | 20 | sendMessage(msgBytes: Uint8Array) { 21 | if (!this.options.methodDefinition.requestStream && !this.options.methodDefinition.responseStream) { 22 | // Disable chunked encoding if we are not using streams 23 | this.request.setHeader("Content-Length", msgBytes.byteLength); 24 | } 25 | this.request.write(toBuffer(msgBytes)); 26 | this.request.end(); 27 | } 28 | 29 | finishSend() { 30 | 31 | } 32 | 33 | responseCallback(response: http.IncomingMessage) { 34 | this.options.debug && console.log("NodeHttp.response", response.statusCode); 35 | const headers = filterHeadersForUndefined(response.headers); 36 | this.options.onHeaders(new grpc.Metadata(headers), response.statusCode!); 37 | 38 | response.on("data", chunk => { 39 | this.options.debug && console.log("NodeHttp.data", chunk); 40 | this.options.onChunk(toArrayBuffer(chunk as Buffer)); 41 | }); 42 | 43 | response.on("end", () => { 44 | this.options.debug && console.log("NodeHttp.end"); 45 | this.options.onEnd(); 46 | }); 47 | }; 48 | 49 | start(metadata: grpc.Metadata) { 50 | const headers: { [key: string]: string } = {}; 51 | metadata.forEach((key, values) => { 52 | headers[key] = values.join(", "); 53 | }); 54 | const parsedUrl = url.parse(this.options.url); 55 | 56 | const httpOptions = { 57 | host: parsedUrl.hostname, 58 | port: parsedUrl.port ? parseInt(parsedUrl.port) : undefined, 59 | path: parsedUrl.path, 60 | headers: headers, 61 | method: "POST" 62 | }; 63 | if (parsedUrl.protocol === "https:") { 64 | this.request = https.request({ ...httpOptions, ...this?.httpsOptions }, this.responseCallback.bind(this)); 65 | } else { 66 | this.request = http.request(httpOptions, this.responseCallback.bind(this)); 67 | } 68 | this.request.on("error", err => { 69 | this.options.debug && console.log("NodeHttp.error", err); 70 | this.options.onEnd(err); 71 | }); 72 | } 73 | 74 | cancel() { 75 | this.options.debug && console.log("NodeHttp.abort"); 76 | this.request.abort(); 77 | } 78 | } 79 | 80 | function filterHeadersForUndefined(headers: {[key: string]: string | string[] | undefined}): {[key: string]: string | string[]} { 81 | const filteredHeaders: {[key: string]: string | string[]} = {}; 82 | 83 | for (let key in headers) { 84 | const value = headers[key]; 85 | if (headers.hasOwnProperty(key)) { 86 | if (value !== undefined) { 87 | filteredHeaders[key] = value; 88 | } 89 | } 90 | } 91 | 92 | return filteredHeaders; 93 | } 94 | 95 | function toArrayBuffer(buf: Buffer): Uint8Array { 96 | const view = new Uint8Array(buf.length); 97 | for (let i = 0; i < buf.length; i++) { 98 | view[i] = buf[i]; 99 | } 100 | return view; 101 | } 102 | 103 | function toBuffer(ab: Uint8Array): Buffer { 104 | const buf = Buffer.alloc(ab.byteLength); 105 | for (let i = 0; i < buf.length; i++) { 106 | buf[i] = ab[i]; 107 | } 108 | return buf; 109 | } 110 | -------------------------------------------------------------------------------- /client/grpc-web-node-http-transport/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "sourceMap": true, 5 | "declaration": true, 6 | "declarationDir": "lib", 7 | "target": "es5", 8 | "removeComments": true, 9 | "noImplicitReturns": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "strictNullChecks": true, 14 | "stripInternal": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "outDir": "lib", 17 | "noEmitOnError": true 18 | }, 19 | "types": [ 20 | "node" 21 | ], 22 | "include": [ 23 | "src" 24 | ], 25 | "exclude": [ 26 | "node_modules" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/README.md: -------------------------------------------------------------------------------- 1 | # gRPC-Web-Example: A simple Golang API server and TypeScript frontend 2 | 3 | ### Get started (with HTTP 1.1) 4 | 5 | * `npm install` 6 | * `npm start` to start the Golang server and Webpack dev server 7 | * Go to `http://localhost:8081` 8 | 9 | 10 | ### Using HTTP2 11 | 12 | HTTP2 requires TLS. This repository contains certificates in the `../misc` directory which are used by the server. You can optionally generate your own replacements using the `gen_cert.sh` in the same directory. 13 | You will need to import the `../misc/localhostCA.pem` certificate authority into your browser, checking the "Trust this CA to identify websites" so that your browser trusts the localhost server. 14 | 15 | * `npm run start:tls` to start the Golang server and Webpack dev server with the certificates in `misc` 16 | * Go to `https://localhost:8082` 17 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/go/exampleserver/exampleserver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/grpclog" 13 | "google.golang.org/grpc/metadata" 14 | 15 | "strings" 16 | 17 | library "github.com/improbable-eng/grpc-web/client/grpc-web-react-example/go/_proto/examplecom/library" 18 | "github.com/improbable-eng/grpc-web/go/grpcweb" 19 | "golang.org/x/net/context" 20 | ) 21 | 22 | var ( 23 | enableTls = flag.Bool("enable_tls", false, "Use TLS - required for HTTP2.") 24 | tlsCertFilePath = flag.String("tls_cert_file", "../../misc/localhost.crt", "Path to the CRT/PEM file.") 25 | tlsKeyFilePath = flag.String("tls_key_file", "../../misc/localhost.key", "Path to the private key file.") 26 | ) 27 | 28 | func main() { 29 | flag.Parse() 30 | 31 | port := 9090 32 | if *enableTls { 33 | port = 9091 34 | } 35 | 36 | grpcServer := grpc.NewServer() 37 | library.RegisterBookServiceServer(grpcServer, &bookService{}) 38 | grpclog.SetLogger(log.New(os.Stdout, "exampleserver: ", log.LstdFlags)) 39 | 40 | wrappedServer := grpcweb.WrapServer(grpcServer) 41 | handler := func(resp http.ResponseWriter, req *http.Request) { 42 | wrappedServer.ServeHTTP(resp, req) 43 | } 44 | 45 | httpServer := http.Server{ 46 | Addr: fmt.Sprintf(":%d", port), 47 | Handler: http.HandlerFunc(handler), 48 | } 49 | 50 | grpclog.Printf("Starting server. http port: %d, with TLS: %v", port, *enableTls) 51 | 52 | if *enableTls { 53 | if err := httpServer.ListenAndServeTLS(*tlsCertFilePath, *tlsKeyFilePath); err != nil { 54 | grpclog.Fatalf("failed starting http2 server: %v", err) 55 | } 56 | } else { 57 | if err := httpServer.ListenAndServe(); err != nil { 58 | grpclog.Fatalf("failed starting http server: %v", err) 59 | } 60 | } 61 | } 62 | 63 | type bookService struct{} 64 | 65 | var books = []*library.Book{ 66 | { 67 | Isbn: 60929871, 68 | Title: "Brave New World", 69 | Author: "Aldous Huxley", 70 | }, 71 | { 72 | Isbn: 140009728, 73 | Title: "Nineteen Eighty-Four", 74 | Author: "George Orwell", 75 | }, 76 | { 77 | Isbn: 9780140301694, 78 | Title: "Alice's Adventures in Wonderland", 79 | Author: "Lewis Carroll", 80 | }, 81 | { 82 | Isbn: 140008381, 83 | Title: "Animal Farm", 84 | Author: "George Orwell", 85 | }, 86 | } 87 | 88 | func (s *bookService) GetBook(ctx context.Context, bookQuery *library.GetBookRequest) (*library.Book, error) { 89 | grpc.SendHeader(ctx, metadata.Pairs("Pre-Response-Metadata", "Is-sent-as-headers-unary")) 90 | grpc.SetTrailer(ctx, metadata.Pairs("Post-Response-Metadata", "Is-sent-as-trailers-unary")) 91 | 92 | for _, book := range books { 93 | if book.Isbn == bookQuery.Isbn { 94 | return book, nil 95 | } 96 | } 97 | 98 | return nil, grpc.Errorf(codes.NotFound, "Book could not be found") 99 | } 100 | 101 | func (s *bookService) QueryBooks(bookQuery *library.QueryBooksRequest, stream library.BookService_QueryBooksServer) error { 102 | stream.SendHeader(metadata.Pairs("Pre-Response-Metadata", "Is-sent-as-headers-stream")) 103 | for _, book := range books { 104 | if strings.HasPrefix(book.Author, bookQuery.AuthorPrefix) { 105 | stream.Send(book) 106 | } 107 | } 108 | stream.SetTrailer(metadata.Pairs("Post-Response-Metadata", "Is-sent-as-trailers-stream")) 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grpc-web-react-example", 3 | "version": "0.15.0", 4 | "private": true, 5 | "scripts": { 6 | "generate_cert": "cd ../misc ./gen_cert.sh", 7 | "build:proto": "./protogen.sh", 8 | "webpack-dev:tls": "cd ts && export USE_TLS=true && webpack serve --watch --hot --inline --port 8082 --host 0.0.0.0 --output-public-path=https://localhost:8082/build/ --https --cert=../../../misc/localhost.crt --key=../../../misc/localhost.key", 9 | "webpack-dev": "cd ts && webpack serve --watch --hot --inline --port 8081 --host 0.0.0.0 --output-public-path=http://localhost:8081/build/", 10 | "start:tls": "npm run build:proto && concurrently --kill-others \"go run go/exampleserver/exampleserver.go --enable_tls=true\" \"npm run webpack-dev:tls\"", 11 | "start": "npm run build:proto && concurrently --kill-others \"go run go/exampleserver/exampleserver.go\" \"npm run webpack-dev\"" 12 | }, 13 | "license": "none", 14 | "dependencies": { 15 | "@improbable-eng/grpc-web": "^0.13.0", 16 | "google-protobuf": "^3.14.0" 17 | }, 18 | "devDependencies": { 19 | "@types/google-protobuf": "^3.7.4", 20 | "concurrently": "^5.3.0", 21 | "ts-loader": "^8.0.14", 22 | "ts-protoc-gen": "0.14.0", 23 | "typescript": "4.1.3", 24 | "webpack": "^5.19.0", 25 | "webpack-cli": "^4.4.0", 26 | "webpack-dev-server": "^3.11.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/proto/examplecom/library/book_service.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package examplecom.library; 4 | 5 | message Book { 6 | int64 isbn = 1; 7 | string title = 2; 8 | string author = 3; 9 | } 10 | 11 | message GetBookRequest { 12 | int64 isbn = 1; 13 | } 14 | 15 | message QueryBooksRequest { 16 | string author_prefix = 1; 17 | } 18 | 19 | service BookService { 20 | rpc GetBook(GetBookRequest) returns (Book) {} 21 | rpc QueryBooks(QueryBooksRequest) returns (stream Book) {} 22 | } 23 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/protogen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p ./ts/_proto 4 | mkdir -p ./go/_proto 5 | 6 | if [[ "$GOBIN" == "" ]]; then 7 | if [[ "$GOPATH" == "" ]]; then 8 | echo "Required env var GOPATH is not set; aborting with error; see the following documentation which can be invoked via the 'go help gopath' command." 9 | go help gopath 10 | exit -1 11 | fi 12 | 13 | echo "Optional env var GOBIN is not set; using default derived from GOPATH as: \"$GOPATH/bin\"" 14 | export GOBIN="$GOPATH/bin" 15 | fi 16 | 17 | PROTOC=`command -v protoc` 18 | if [[ "$PROTOC" == "" ]]; then 19 | echo "Required "protoc" to be installed. Please visit https://github.com/protocolbuffers/protobuf/releases (3.5.0 suggested)." 20 | exit -1 21 | fi 22 | 23 | # Install protoc-gen-go from the vendored protobuf package to $GOBIN 24 | (cd ../../vendor/github.com/golang/protobuf && make install) 25 | 26 | echo "Compiling protobuf definitions" 27 | protoc \ 28 | --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \ 29 | --plugin=protoc-gen-go=${GOBIN}/protoc-gen-go \ 30 | -I ./proto \ 31 | --js_out=import_style=commonjs,binary:./ts/_proto \ 32 | --go_out=plugins=grpc:./go/_proto \ 33 | --ts_out=service=grpc-web:./ts/_proto \ 34 | ./proto/examplecom/library/book_service.proto 35 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/ts/_proto/examplecom/library/book_service_pb.d.ts: -------------------------------------------------------------------------------- 1 | // package: examplecom.library 2 | // file: examplecom/library/book_service.proto 3 | 4 | import * as jspb from "google-protobuf"; 5 | 6 | export class Book extends jspb.Message { 7 | getIsbn(): number; 8 | setIsbn(value: number): void; 9 | 10 | getTitle(): string; 11 | setTitle(value: string): void; 12 | 13 | getAuthor(): string; 14 | setAuthor(value: string): void; 15 | 16 | serializeBinary(): Uint8Array; 17 | toObject(includeInstance?: boolean): Book.AsObject; 18 | static toObject(includeInstance: boolean, msg: Book): Book.AsObject; 19 | static extensions: {[key: number]: jspb.ExtensionFieldInfo}; 20 | static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; 21 | static serializeBinaryToWriter(message: Book, writer: jspb.BinaryWriter): void; 22 | static deserializeBinary(bytes: Uint8Array): Book; 23 | static deserializeBinaryFromReader(message: Book, reader: jspb.BinaryReader): Book; 24 | } 25 | 26 | export namespace Book { 27 | export type AsObject = { 28 | isbn: number, 29 | title: string, 30 | author: string, 31 | } 32 | } 33 | 34 | export class GetBookRequest extends jspb.Message { 35 | getIsbn(): number; 36 | setIsbn(value: number): void; 37 | 38 | serializeBinary(): Uint8Array; 39 | toObject(includeInstance?: boolean): GetBookRequest.AsObject; 40 | static toObject(includeInstance: boolean, msg: GetBookRequest): GetBookRequest.AsObject; 41 | static extensions: {[key: number]: jspb.ExtensionFieldInfo}; 42 | static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; 43 | static serializeBinaryToWriter(message: GetBookRequest, writer: jspb.BinaryWriter): void; 44 | static deserializeBinary(bytes: Uint8Array): GetBookRequest; 45 | static deserializeBinaryFromReader(message: GetBookRequest, reader: jspb.BinaryReader): GetBookRequest; 46 | } 47 | 48 | export namespace GetBookRequest { 49 | export type AsObject = { 50 | isbn: number, 51 | } 52 | } 53 | 54 | export class QueryBooksRequest extends jspb.Message { 55 | getAuthorPrefix(): string; 56 | setAuthorPrefix(value: string): void; 57 | 58 | serializeBinary(): Uint8Array; 59 | toObject(includeInstance?: boolean): QueryBooksRequest.AsObject; 60 | static toObject(includeInstance: boolean, msg: QueryBooksRequest): QueryBooksRequest.AsObject; 61 | static extensions: {[key: number]: jspb.ExtensionFieldInfo}; 62 | static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; 63 | static serializeBinaryToWriter(message: QueryBooksRequest, writer: jspb.BinaryWriter): void; 64 | static deserializeBinary(bytes: Uint8Array): QueryBooksRequest; 65 | static deserializeBinaryFromReader(message: QueryBooksRequest, reader: jspb.BinaryReader): QueryBooksRequest; 66 | } 67 | 68 | export namespace QueryBooksRequest { 69 | export type AsObject = { 70 | authorPrefix: string, 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/ts/_proto/examplecom/library/book_service_pb_service.d.ts: -------------------------------------------------------------------------------- 1 | // package: examplecom.library 2 | // file: examplecom/library/book_service.proto 3 | 4 | import * as examplecom_library_book_service_pb from "../../examplecom/library/book_service_pb"; 5 | import {grpc} from "@improbable-eng/grpc-web"; 6 | 7 | type BookServiceGetBook = { 8 | readonly methodName: string; 9 | readonly service: typeof BookService; 10 | readonly requestStream: false; 11 | readonly responseStream: false; 12 | readonly requestType: typeof examplecom_library_book_service_pb.GetBookRequest; 13 | readonly responseType: typeof examplecom_library_book_service_pb.Book; 14 | }; 15 | 16 | type BookServiceQueryBooks = { 17 | readonly methodName: string; 18 | readonly service: typeof BookService; 19 | readonly requestStream: false; 20 | readonly responseStream: true; 21 | readonly requestType: typeof examplecom_library_book_service_pb.QueryBooksRequest; 22 | readonly responseType: typeof examplecom_library_book_service_pb.Book; 23 | }; 24 | 25 | export class BookService { 26 | static readonly serviceName: string; 27 | static readonly GetBook: BookServiceGetBook; 28 | static readonly QueryBooks: BookServiceQueryBooks; 29 | } 30 | 31 | export type ServiceError = { message: string, code: number; metadata: grpc.Metadata } 32 | export type Status = { details: string, code: number; metadata: grpc.Metadata } 33 | 34 | interface UnaryResponse { 35 | cancel(): void; 36 | } 37 | interface ResponseStream { 38 | cancel(): void; 39 | on(type: 'data', handler: (message: T) => void): ResponseStream; 40 | on(type: 'end', handler: (status?: Status) => void): ResponseStream; 41 | on(type: 'status', handler: (status: Status) => void): ResponseStream; 42 | } 43 | interface RequestStream { 44 | write(message: T): RequestStream; 45 | end(): void; 46 | cancel(): void; 47 | on(type: 'end', handler: (status?: Status) => void): RequestStream; 48 | on(type: 'status', handler: (status: Status) => void): RequestStream; 49 | } 50 | interface BidirectionalStream { 51 | write(message: ReqT): BidirectionalStream; 52 | end(): void; 53 | cancel(): void; 54 | on(type: 'data', handler: (message: ResT) => void): BidirectionalStream; 55 | on(type: 'end', handler: (status?: Status) => void): BidirectionalStream; 56 | on(type: 'status', handler: (status: Status) => void): BidirectionalStream; 57 | } 58 | 59 | export class BookServiceClient { 60 | readonly serviceHost: string; 61 | 62 | constructor(serviceHost: string, options?: grpc.RpcOptions); 63 | getBook( 64 | requestMessage: examplecom_library_book_service_pb.GetBookRequest, 65 | metadata: grpc.Metadata, 66 | callback: (error: ServiceError|null, responseMessage: examplecom_library_book_service_pb.Book|null) => void 67 | ): UnaryResponse; 68 | getBook( 69 | requestMessage: examplecom_library_book_service_pb.GetBookRequest, 70 | callback: (error: ServiceError|null, responseMessage: examplecom_library_book_service_pb.Book|null) => void 71 | ): UnaryResponse; 72 | queryBooks(requestMessage: examplecom_library_book_service_pb.QueryBooksRequest, metadata?: grpc.Metadata): ResponseStream; 73 | } 74 | 75 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/ts/_proto/examplecom/library/book_service_pb_service.js: -------------------------------------------------------------------------------- 1 | // package: examplecom.library 2 | // file: examplecom/library/book_service.proto 3 | 4 | var examplecom_library_book_service_pb = require("../../examplecom/library/book_service_pb"); 5 | var grpc = require("@improbable-eng/grpc-web").grpc; 6 | 7 | var BookService = (function () { 8 | function BookService() {} 9 | BookService.serviceName = "examplecom.library.BookService"; 10 | return BookService; 11 | }()); 12 | 13 | BookService.GetBook = { 14 | methodName: "GetBook", 15 | service: BookService, 16 | requestStream: false, 17 | responseStream: false, 18 | requestType: examplecom_library_book_service_pb.GetBookRequest, 19 | responseType: examplecom_library_book_service_pb.Book 20 | }; 21 | 22 | BookService.QueryBooks = { 23 | methodName: "QueryBooks", 24 | service: BookService, 25 | requestStream: false, 26 | responseStream: true, 27 | requestType: examplecom_library_book_service_pb.QueryBooksRequest, 28 | responseType: examplecom_library_book_service_pb.Book 29 | }; 30 | 31 | exports.BookService = BookService; 32 | 33 | function BookServiceClient(serviceHost, options) { 34 | this.serviceHost = serviceHost; 35 | this.options = options || {}; 36 | } 37 | 38 | BookServiceClient.prototype.getBook = function getBook(requestMessage, metadata, callback) { 39 | if (arguments.length === 2) { 40 | callback = arguments[1]; 41 | } 42 | var client = grpc.unary(BookService.GetBook, { 43 | request: requestMessage, 44 | host: this.serviceHost, 45 | metadata: metadata, 46 | transport: this.options.transport, 47 | debug: this.options.debug, 48 | onEnd: function (response) { 49 | if (callback) { 50 | if (response.status !== grpc.Code.OK) { 51 | var err = new Error(response.statusMessage); 52 | err.code = response.status; 53 | err.metadata = response.trailers; 54 | callback(err, null); 55 | } else { 56 | callback(null, response.message); 57 | } 58 | } 59 | } 60 | }); 61 | return { 62 | cancel: function () { 63 | callback = null; 64 | client.close(); 65 | } 66 | }; 67 | }; 68 | 69 | BookServiceClient.prototype.queryBooks = function queryBooks(requestMessage, metadata) { 70 | var listeners = { 71 | data: [], 72 | end: [], 73 | status: [] 74 | }; 75 | var client = grpc.invoke(BookService.QueryBooks, { 76 | request: requestMessage, 77 | host: this.serviceHost, 78 | metadata: metadata, 79 | transport: this.options.transport, 80 | debug: this.options.debug, 81 | onMessage: function (responseMessage) { 82 | listeners.data.forEach(function (handler) { 83 | handler(responseMessage); 84 | }); 85 | }, 86 | onEnd: function (status, statusMessage, trailers) { 87 | listeners.status.forEach(function (handler) { 88 | handler({ code: status, details: statusMessage, metadata: trailers }); 89 | }); 90 | listeners.end.forEach(function (handler) { 91 | handler({ code: status, details: statusMessage, metadata: trailers }); 92 | }); 93 | listeners = null; 94 | } 95 | }); 96 | return { 97 | on: function (type, handler) { 98 | listeners[type].push(handler); 99 | return this; 100 | }, 101 | cancel: function () { 102 | listeners = null; 103 | client.close(); 104 | } 105 | }; 106 | }; 107 | 108 | exports.BookServiceClient = BookServiceClient; 109 | 110 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/ts/_proto/examplecom/library/book_service_pb_service.ts: -------------------------------------------------------------------------------- 1 | // package: examplecom.library 2 | // file: examplecom/library/book_service.proto 3 | 4 | import * as examplecom_library_book_service_pb from "../../examplecom/library/book_service_pb"; 5 | export class BookService { 6 | static serviceName = "examplecom.library.BookService"; 7 | } 8 | export namespace BookService { 9 | export class GetBook { 10 | static readonly methodName = "GetBook"; 11 | static readonly service = BookService; 12 | static readonly requestStream = false; 13 | static readonly responseStream = false; 14 | static readonly requestType = examplecom_library_book_service_pb.GetBookRequest; 15 | static readonly responseType = examplecom_library_book_service_pb.Book; 16 | } 17 | export class QueryBooks { 18 | static readonly methodName = "QueryBooks"; 19 | static readonly service = BookService; 20 | static readonly requestStream = false; 21 | static readonly responseStream = true; 22 | static readonly requestType = examplecom_library_book_service_pb.QueryBooksRequest; 23 | static readonly responseType = examplecom_library_book_service_pb.Book; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/ts/index.html: -------------------------------------------------------------------------------- 1 | 2 | Output in developer console 3 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/ts/src/index.ts: -------------------------------------------------------------------------------- 1 | import {grpc} from "@improbable-eng/grpc-web"; 2 | import {BookService} from "../_proto/examplecom/library/book_service_pb_service"; 3 | import {QueryBooksRequest, Book, GetBookRequest} from "../_proto/examplecom/library/book_service_pb"; 4 | 5 | declare const USE_TLS: boolean; 6 | const host = USE_TLS ? "https://localhost:9091" : "http://localhost:9090"; 7 | 8 | function getBook() { 9 | const getBookRequest = new GetBookRequest(); 10 | getBookRequest.setIsbn(60929871); 11 | grpc.unary(BookService.GetBook, { 12 | request: getBookRequest, 13 | host: host, 14 | onEnd: res => { 15 | const { status, statusMessage, headers, message, trailers } = res; 16 | console.log("getBook.onEnd.status", status, statusMessage); 17 | console.log("getBook.onEnd.headers", headers); 18 | if (status === grpc.Code.OK && message) { 19 | console.log("getBook.onEnd.message", message.toObject()); 20 | } 21 | console.log("getBook.onEnd.trailers", trailers); 22 | queryBooks(); 23 | } 24 | }); 25 | } 26 | 27 | getBook(); 28 | 29 | function queryBooks() { 30 | const queryBooksRequest = new QueryBooksRequest(); 31 | queryBooksRequest.setAuthorPrefix("Geor"); 32 | const client = grpc.client(BookService.QueryBooks, { 33 | host: host, 34 | }); 35 | client.onHeaders((headers: grpc.Metadata) => { 36 | console.log("queryBooks.onHeaders", headers); 37 | }); 38 | client.onMessage((message: Book) => { 39 | console.log("queryBooks.onMessage", message.toObject()); 40 | }); 41 | client.onEnd((code: grpc.Code, msg: string, trailers: grpc.Metadata) => { 42 | console.log("queryBooks.onEnd", code, msg, trailers); 43 | }); 44 | client.start(); 45 | client.send(queryBooksRequest); 46 | } 47 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "sourceMap": true, 5 | "target": "es5", 6 | "removeComments": true, 7 | "noImplicitReturns": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "stripInternal": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noEmitOnError": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/grpc-web-react-example/ts/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | entry: "./src/index.ts", 6 | mode: "development", 7 | output: { 8 | path: path.resolve(__dirname, 'build'), 9 | filename: 'bundle.js' 10 | }, 11 | devtool: 'inline-source-map', 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.ts$/, 16 | include: /src|_proto/, 17 | exclude: /node_modules/, 18 | loader: "ts-loader" 19 | } 20 | ] 21 | }, 22 | resolve: { 23 | extensions: [".ts", ".js"] 24 | }, 25 | plugins: [ 26 | new webpack.DefinePlugin({ 27 | 'USE_TLS': process.env.USE_TLS !== undefined 28 | }) 29 | ] 30 | }; 31 | -------------------------------------------------------------------------------- /client/grpc-web-react-native-transport/README.md: -------------------------------------------------------------------------------- 1 | # @improbable-eng/grpc-web-react-native-transport 2 | Transport for use with [@improbable-eng/grpc-web](https://github.com/improbable-eng/grpc-web) 3 | that works with React Native. 4 | 5 | ## Usage 6 | When making a gRPC request, specify this transport: 7 | 8 | ```typescript 9 | import { grpc } from '@improbable-eng/grpc-web'; 10 | import { ReactNativeTransport } from '@improbable-eng/grpc-web-react-native-transport'; 11 | 12 | grpc.invoke(MyService.DoQuery, { 13 | host: "https://example.com", 14 | transport: ReactNativeTransport(), 15 | /* ... */ 16 | }) 17 | ``` 18 | 19 | Alternatively specify the Default Transport when your server/application bootstraps: 20 | ```typescript 21 | import { grpc } from '@improbable-eng/grpc-web'; 22 | import { ReactNativeTransport } from '@improbable-eng/grpc-web-react-native-transport'; 23 | 24 | // Do this first, before you make any grpc requests! 25 | grpc.setDefaultTransport(ReactNativeTransport()); 26 | ``` 27 | -------------------------------------------------------------------------------- /client/grpc-web-react-native-transport/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@improbable-eng/grpc-web-react-native-transport", 3 | "version": "0.15.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@improbable-eng/grpc-web-react-native-transport", 9 | "version": "0.15.0", 10 | "license": "Apache-2.0", 11 | "devDependencies": { 12 | "@improbable-eng/grpc-web": "^0.13.0", 13 | "google-protobuf": "^3.14.0", 14 | "typescript": "^4.1.3" 15 | }, 16 | "peerDependencies": { 17 | "@improbable-eng/grpc-web": ">=0.13.0" 18 | } 19 | }, 20 | "node_modules/@improbable-eng/grpc-web": { 21 | "version": "0.13.0", 22 | "resolved": "https://registry.npmjs.org/@improbable-eng/grpc-web/-/grpc-web-0.13.0.tgz", 23 | "integrity": "sha512-vaxxT+Qwb7GPqDQrBV4vAAfH0HywgOLw6xGIKXd9Q8hcV63CQhmS3p4+pZ9/wVvt4Ph3ZDK9fdC983b9aGMUFg==", 24 | "dev": true, 25 | "dependencies": { 26 | "browser-headers": "^0.4.0" 27 | }, 28 | "peerDependencies": { 29 | "google-protobuf": "^3.2.0" 30 | } 31 | }, 32 | "node_modules/browser-headers": { 33 | "version": "0.4.1", 34 | "resolved": "https://registry.npmjs.org/browser-headers/-/browser-headers-0.4.1.tgz", 35 | "integrity": "sha512-CA9hsySZVo9371qEHjHZtYxV2cFtVj5Wj/ZHi8ooEsrtm4vOnl9Y9HmyYWk9q+05d7K3rdoAE0j3MVEFVvtQtg==", 36 | "dev": true 37 | }, 38 | "node_modules/google-protobuf": { 39 | "version": "3.14.0", 40 | "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.14.0.tgz", 41 | "integrity": "sha512-bwa8dBuMpOxg7COyqkW6muQuvNnWgVN8TX/epDRGW5m0jcrmq2QJyCyiV8ZE2/6LaIIqJtiv9bYokFhfpy/o6w==", 42 | "dev": true 43 | }, 44 | "node_modules/typescript": { 45 | "version": "4.1.3", 46 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", 47 | "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", 48 | "dev": true, 49 | "bin": { 50 | "tsc": "bin/tsc", 51 | "tsserver": "bin/tsserver" 52 | }, 53 | "engines": { 54 | "node": ">=4.2.0" 55 | } 56 | } 57 | }, 58 | "dependencies": { 59 | "@improbable-eng/grpc-web": { 60 | "version": "0.13.0", 61 | "resolved": "https://registry.npmjs.org/@improbable-eng/grpc-web/-/grpc-web-0.13.0.tgz", 62 | "integrity": "sha512-vaxxT+Qwb7GPqDQrBV4vAAfH0HywgOLw6xGIKXd9Q8hcV63CQhmS3p4+pZ9/wVvt4Ph3ZDK9fdC983b9aGMUFg==", 63 | "dev": true, 64 | "requires": { 65 | "browser-headers": "^0.4.0" 66 | } 67 | }, 68 | "browser-headers": { 69 | "version": "0.4.1", 70 | "resolved": "https://registry.npmjs.org/browser-headers/-/browser-headers-0.4.1.tgz", 71 | "integrity": "sha512-CA9hsySZVo9371qEHjHZtYxV2cFtVj5Wj/ZHi8ooEsrtm4vOnl9Y9HmyYWk9q+05d7K3rdoAE0j3MVEFVvtQtg==", 72 | "dev": true 73 | }, 74 | "google-protobuf": { 75 | "version": "3.14.0", 76 | "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.14.0.tgz", 77 | "integrity": "sha512-bwa8dBuMpOxg7COyqkW6muQuvNnWgVN8TX/epDRGW5m0jcrmq2QJyCyiV8ZE2/6LaIIqJtiv9bYokFhfpy/o6w==", 78 | "dev": true 79 | }, 80 | "typescript": { 81 | "version": "4.1.3", 82 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", 83 | "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", 84 | "dev": true 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /client/grpc-web-react-native-transport/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@improbable-eng/grpc-web-react-native-transport", 3 | "version": "0.15.0", 4 | "description": "Transport for use with @improbable-eng/grpc-web that works with React Native.", 5 | "main": "lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "github.com/improbable-eng/grpc-web" 9 | }, 10 | "scripts": { 11 | "clean": "rm -rf lib", 12 | "postbootstrap": "npm run build", 13 | "build": "tsc" 14 | }, 15 | "publishConfig": { 16 | "access": "public" 17 | }, 18 | "author": "Improbable", 19 | "license": "Apache-2.0", 20 | "peerDependencies": { 21 | "@improbable-eng/grpc-web": ">=0.13.0" 22 | }, 23 | "devDependencies": { 24 | "@improbable-eng/grpc-web": "^0.13.0", 25 | "google-protobuf": "^3.14.0", 26 | "typescript": "^4.1.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/grpc-web-react-native-transport/src/index.ts: -------------------------------------------------------------------------------- 1 | import { grpc } from "@improbable-eng/grpc-web"; 2 | 3 | export function ReactNativeTransport(init: grpc.XhrTransportInit): grpc.TransportFactory { 4 | return (opts: grpc.TransportOptions) => { 5 | return new ArrayBufferXHR(opts, init); 6 | } 7 | } 8 | 9 | let awaitingExecution: Array<() => void> | null = null; 10 | 11 | function runCallbacks() { 12 | if (awaitingExecution) { 13 | const thisCallbackSet = awaitingExecution; 14 | awaitingExecution = null; 15 | for (let i = 0; i < thisCallbackSet.length; i++) { 16 | try { 17 | thisCallbackSet[i](); 18 | } catch (e) { 19 | if (awaitingExecution === null) { 20 | awaitingExecution = []; 21 | setTimeout(() => { 22 | runCallbacks(); 23 | }, 0); 24 | } 25 | for (let k = thisCallbackSet.length - 1; k > i; k--) { 26 | awaitingExecution.unshift(thisCallbackSet[k]); 27 | } 28 | throw e; 29 | } 30 | } 31 | } 32 | } 33 | 34 | function debug(...args: any[]) { 35 | if (console.debug) { 36 | console.debug.apply(null, args); 37 | } else { 38 | console.log.apply(null, args); 39 | } 40 | } 41 | 42 | function detach(cb: () => void) { 43 | if (awaitingExecution !== null) { 44 | awaitingExecution.push(cb); 45 | return; 46 | } 47 | awaitingExecution = [cb]; 48 | setTimeout(() => { 49 | runCallbacks(); 50 | }, 0); 51 | } 52 | 53 | function stringToArrayBuffer(str: string): Uint8Array { 54 | const asArray = new Uint8Array(str.length); 55 | let arrayIndex = 0; 56 | for (let i = 0; i < str.length; i++) { 57 | const codePoint = (String.prototype as any).codePointAt ? (str as any).codePointAt(i) : codePointAtPolyfill(str, i); 58 | asArray[arrayIndex++] = codePoint & 0xFF; 59 | } 60 | return asArray; 61 | } 62 | 63 | function codePointAtPolyfill(str: string, index: number) { 64 | let code = str.charCodeAt(index); 65 | if (code >= 0xd800 && code <= 0xdbff) { 66 | const surr = str.charCodeAt(index + 1); 67 | if (surr >= 0xdc00 && surr <= 0xdfff) { 68 | code = 0x10000 + ((code - 0xd800) << 10) + (surr - 0xdc00); 69 | } 70 | } 71 | return code; 72 | } 73 | 74 | class XHR implements grpc.Transport { 75 | options: grpc.TransportOptions; 76 | init: grpc.XhrTransportInit; 77 | xhr: XMLHttpRequest; 78 | metadata: grpc.Metadata; 79 | index: 0; 80 | 81 | constructor(transportOptions: grpc.TransportOptions, init: grpc.XhrTransportInit) { 82 | this.options = transportOptions; 83 | this.init = init; 84 | } 85 | 86 | protected onProgressEvent() { 87 | this.options.debug && debug("XHR.onProgressEvent.length: ", this.xhr.response.length); 88 | const rawText = this.xhr.response.substr(this.index); 89 | this.index = this.xhr.response.length; 90 | const asArrayBuffer = stringToArrayBuffer(rawText); 91 | detach(() => { 92 | this.options.onChunk(asArrayBuffer); 93 | }); 94 | } 95 | 96 | onLoadEvent() { 97 | detach(() => { 98 | this.options.onEnd(); 99 | }); 100 | } 101 | 102 | onStateChange() { 103 | if (this.xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) { 104 | detach(() => { 105 | this.options.onHeaders(new grpc.Metadata( 106 | this.xhr.getAllResponseHeaders()), this.xhr.status); 107 | }); 108 | } 109 | } 110 | 111 | sendMessage(msgBytes: Uint8Array) { 112 | this.xhr.send(msgBytes); 113 | } 114 | 115 | finishSend() { } 116 | 117 | start(metadata: grpc.Metadata) { 118 | this.metadata = metadata; 119 | 120 | const xhr = new XMLHttpRequest(); 121 | this.xhr = xhr; 122 | xhr.open("POST", this.options.url); 123 | 124 | this.configureXhr(); 125 | 126 | this.metadata.forEach((key, values) => { 127 | xhr.setRequestHeader(key, values.join(", ")); 128 | }); 129 | 130 | xhr.withCredentials = Boolean(this.init.withCredentials); 131 | 132 | xhr.addEventListener("readystatechange", this.onStateChange.bind(this)); 133 | xhr.addEventListener("progress", this.onProgressEvent.bind(this)); 134 | xhr.addEventListener("loadend", this.onLoadEvent.bind(this)); 135 | xhr.addEventListener("error", (err: ErrorEvent) => { 136 | detach(() => { 137 | this.options.onEnd(err.error); 138 | }); 139 | }); 140 | } 141 | 142 | protected configureXhr(): void { 143 | this.xhr.responseType = "text"; 144 | this.xhr.overrideMimeType("text/plain; charset=x-user-defined"); 145 | } 146 | 147 | cancel() { 148 | this.xhr.abort(); 149 | } 150 | } 151 | 152 | class ArrayBufferXHR extends XHR { 153 | configureXhr(): void { 154 | this.options.debug && debug("ArrayBufferXHR.configureXhr: setting responseType to 'arraybuffer'"); 155 | (this.xhr as any).responseType = "arraybuffer"; 156 | } 157 | 158 | onProgressEvent() { } 159 | 160 | onLoadEvent(): void { 161 | const resp = this.xhr.response; 162 | this.options.debug && debug("ArrayBufferXHR.onLoadEvent: ", new Uint8Array(resp)); 163 | detach(() => { 164 | this.options.onChunk(new Uint8Array(resp), /* flush */ true); 165 | }); 166 | detach(() => { 167 | this.options.onEnd(); 168 | }); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /client/grpc-web-react-native-transport/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "sourceMap": true, 5 | "declaration": true, 6 | "declarationDir": "lib", 7 | "target": "es5", 8 | "removeComments": true, 9 | "noImplicitReturns": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "strictNullChecks": true, 14 | "stripInternal": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "outDir": "lib", 17 | "noEmitOnError": true 18 | }, 19 | "include": [ 20 | "src" 21 | ], 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /client/grpc-web/.gitignore: -------------------------------------------------------------------------------- 1 | **/npm-debug.log 2 | **/build/ 3 | **/node_modules/ 4 | npm-debug.log -------------------------------------------------------------------------------- /client/grpc-web/.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | src/ 3 | build/ 4 | .* 5 | release.sh -------------------------------------------------------------------------------- /client/grpc-web/README.md: -------------------------------------------------------------------------------- 1 | # @improbable-eng/grpc-web 2 | > Library for making gRPC-Web requests from a browser 3 | 4 | This library is intended for both JavaScript and TypeScript usage from a web browser or NodeJS (see [Usage with NodeJS](#usage-with-nodejs)). 5 | 6 | *Note: This only works if the server supports [gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md)* 7 | 8 | A Golang gRPC-Web middleware and a Golang-based gRPC-Web proxy are [available here](https://github.com/improbable-eng/grpc-web). 9 | 10 | Please see the full [gRPC-Web README](https://github.com/improbable-eng/grpc-web) for known limitations. 11 | 12 | ## Installation 13 | 14 | `@improbable-eng/grpc-web` has peer dependencies of `google-protobuf` and `@types/google-protobuf`. 15 | 16 | `npm install google-protobuf @types/google-protobuf @improbable-eng/grpc-web --save` 17 | 18 | ## Example Project 19 | 20 | There is an [example project available here](https://github.com/improbable-eng/grpc-web/tree/master/client/grpc-web-react-example) 21 | 22 | ## Usage Overview 23 | * Use [`ts-protoc-gen`](https://www.npmjs.com/package/ts-protoc-gen) with [`protoc`](https://github.com/google/protobuf) to generate `.js` and `.d.ts` files for your request and response classes. `ts-protoc-gen` can also generate gRPC service definitions with the `service=true` argument. 24 | * [Go to code generation docs](docs/code-generation.md) 25 | * Make a request using [`unary()`](docs/unary.md), [`invoke()`](docs/invoke.md) or [`client()`](docs/client.md) 26 | 27 | ```javascript 28 | import {grpc} from "@improbable-eng/grpc-web"; 29 | 30 | // Import code-generated data structures. 31 | import {BookService} from "./generated/proto/examplecom/library/book_service_pb_service"; 32 | import {GetBookRequest} from "./generated/proto/examplecom/library/book_service_pb"; 33 | 34 | const getBookRequest = new GetBookRequest(); 35 | getBookRequest.setIsbn(60929871); 36 | grpc.unary(BookService.GetBook, { 37 | request: getBookRequest, 38 | host: host, 39 | onEnd: res => { 40 | const { status, statusMessage, headers, message, trailers } = res; 41 | if (status === grpc.Code.OK && message) { 42 | console.log("all ok. got book: ", message.toObject()); 43 | } 44 | } 45 | }); 46 | ``` 47 | 48 | * Requests can be aborted/closed before they complete: 49 | 50 | ```javascript 51 | const request = grpc.unary(BookService.GetBook, { ... }); 52 | request.close(); 53 | ``` 54 | 55 | ## Available Request Functions 56 | 57 | There are three functions for making gRPC requests: 58 | 59 | ### [`grpc.unary`](docs/unary.md) 60 | This is a convenience function for making requests that consist of a single request message and single response message. It can only be used with unary methods. 61 | 62 | ```protobuf 63 | rpc GetBook(GetBookRequest) returns (Book) {} 64 | ``` 65 | 66 | ### [`grpc.invoke`](docs/invoke.md) 67 | This is a convenience function for making requests that consist of a single request message and a stream of response messages (server-streaming). It can also be used with unary methods. 68 | 69 | ```protobuf 70 | rpc GetBook(GetBookRequest) returns (Book) {} 71 | rpc QueryBooks(QueryBooksRequest) returns (stream Book) {} 72 | ``` 73 | 74 | ### [`grpc.client`](docs/client.md) 75 | `grpc.client` returns a client. Dependant upon [transport compatibility](docs/transport.md) this client is capable of sending multiple request messages (client-streaming) and receiving multiple response messages (server-streaming). It can be used with any type of method, but will enforce limiting the sending of messages for unary methods. 76 | 77 | ```protobuf 78 | rpc GetBook(GetBookRequest) returns (Book) {} 79 | rpc QueryBooks(QueryBooksRequest) returns (stream Book) {} 80 | rpc LogReadPages(stream PageRead) returns (google.protobuf.Empty) {} 81 | rpc ListenForBooks(stream QueryBooksRequest) returns (stream Book) {} 82 | ``` 83 | 84 | ## Usage with NodeJS 85 | Refer to [grpc-web-node-http-transport](https://www.npmjs.com/package/@improbable-eng/grpc-web-node-http-transport). 86 | 87 | ## All Docs 88 | 89 | * [unary()](docs/unary.md) 90 | * [invoke()](docs/invoke.md) 91 | * [client()](docs/client.md) 92 | * [Code Generation](docs/code-generation.md) 93 | * [Concepts](docs/concepts.md) 94 | * [Transport](docs/transport.md) 95 | * [Client-side streaming](docs/websocket.md) 96 | -------------------------------------------------------------------------------- /client/grpc-web/docs/client.md: -------------------------------------------------------------------------------- 1 | # grpc.client 2 | 3 | `grpc.client` allows making any type of gRPC request and exposes a client for the request that can be used to send multiple messages and attach callbacks to the request's lifecycle. 4 | 5 | **The default transports are not capable of bi-directional streaming - this implementation exists to support future transports such as websockets that are capable of bi-directional streaming.** 6 | 7 | ## API Docs: 8 | ```javascript 9 | grpc.client(methodDescriptor: MethodDescriptor, props: ClientRpcOptions): Client; 10 | ``` 11 | 12 | `methodDescriptor` is a generated method definition ([see code generation for how to generate these](code-generation.md)). 13 | 14 | #### `ClientRpcOptions`: 15 | 16 | * `host: string` 17 | * The server address (`"https://example.com:9100"`) 18 | * `transport?: TransportFactory` 19 | * (optional) A function to build a `Transport` that will be used for the request. If no transport is specified then a browser-compatible transport will be used. See [transport](transport.md). 20 | * `debug?: boolean` 21 | * (optional) if `true`, debug information will be printed to the console 22 | 23 | #### `Client`: 24 | ```javascript 25 | // Open the connection to the server 26 | start(metadata?: grpc.Metadata): void; 27 | 28 | // Send a single instance of the request message type 29 | send(message: grpc.ProtobufMessage): void; 30 | 31 | // Indicate to the server that the client has finished sending 32 | finishSend(): void; 33 | 34 | // Close the connection to the server without waiting for any response 35 | close(): void; 36 | 37 | // Attach a callback for headers being received 38 | onHeaders(callback: (headers: grpc.Metadata) => void): void; 39 | 40 | // Attach a callback for messages being received 41 | onMessage(callback: (response: grpc.ProtobufMessage) => void): void; 42 | 43 | // Attach a callback for the end of the request and trailers being received 44 | onEnd(callback: (code: grpc.Code, message: string, trailers: grpc.Metadata) => void): void; 45 | ``` 46 | 47 | ### Lifecycle 48 | A gRPC request goes through the following stages: 49 | 50 | * Request is opened with optional metadata - `start()` 51 | * Client sends one (or more if non-unary) message(s) to the server - `send()` 52 | * Client optionally indicates that it has finished sending - `finishSend()` 53 | * Server sends headers (metadata) - `onHeaders()` 54 | * Server responds with one (or more if non-unary) message(s) to the client - `onMessage()` 55 | * Server closes the request with status code and trailers (metadata) - `onEnd()` 56 | 57 | ### Transport Limitations 58 | 59 | Sending multiple messages and indicating that the client has finished sending are complicated by the nature of some of the transports used by `@improbable-eng/grpc-web`. 60 | 61 | Most browser networking methods do not allow control over the sending of the body of the request, meaning that sending a single request message forces the finishing of sending, limiting these transports to unary or server-streaming methods only. 62 | 63 | For transports that do allow control over the sending of the body (e.g. websockets), the client can optionally indicate that it has finished sending. This is useful for client-streaming or bi-directional methods in which the server will send responses after receiving all client messages. Usage with unary methods is likely not necessary as server handlers will assume the client has finished sending after receiving the single expected message. 64 | 65 | ## Example: 66 | ```javascript 67 | const request = new QueryBooksRequest(); 68 | request.setAuthorPrefix("Geor"); 69 | 70 | const client = grpc.client(BookService.QueryBooks, { 71 | host: "https://example.com:9100", 72 | }); 73 | client.onHeaders((headers: grpc.Metadata) => { 74 | console.log("onHeaders", headers); 75 | }); 76 | client.onMessage((message: Book) => { 77 | console.log("onMessage", message); 78 | }); 79 | client.onEnd((status: grpc.Code, statusMessage: string, trailers: grpc.Metadata) => { 80 | console.log("onEnd", status, statusMessage, trailers); 81 | }); 82 | 83 | client.start(new grpc.Metadata({"HeaderTestKey1": "ClientValue1"})); 84 | client.send(request); 85 | client.finishSend(); // included for completeness, but likely unnecessary as the request is unary 86 | ``` -------------------------------------------------------------------------------- /client/grpc-web/docs/code-generation.md: -------------------------------------------------------------------------------- 1 | # Code Generation 2 | 3 | To make gRPC requests the client requires generated code for the [method definitions](concepts.md#method-definition) and message classes that are [defined in `.proto` files](https://developers.google.com/protocol-buffers/docs/proto3#services). 4 | 5 | [`protoc`](https://github.com/google/protobuf) is the google protobuf code generation tool. It can generate the JavaScript message classes and also supports using plugins for additional code generation. 6 | 7 | [`ts-protoc-gen`](https://www.github.com/improbable-eng/ts-protoc-gen) is a package that can generate the `.d.ts` files that declare the contents of the protoc-generated JavaScript files. `ts-protoc-gen` can also generate `@improbable-eng/grpc-web` client service/method definitions with the `protoc-gen-ts` plugin and `service=true` argument. 8 | 9 | This is an example of a complete invokation of `protoc` with `ts-protoc-gen` assuming your `.proto` files are in a directory named `my-protos` within the current working directory: 10 | 11 | ```bash 12 | protoc \ 13 | --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \ 14 | --js_out=import_style=commonjs,binary:my-generated-code \ 15 | --ts_out=service=grpc-web:my-generated-code \ 16 | -I ./my-protos \ 17 | my-protos/*.proto 18 | ``` 19 | 20 | A proto file such as `book_service.proto`: 21 | 22 | ```protobuf 23 | syntax = "proto3"; 24 | 25 | package examplecom.library; 26 | 27 | message Book { 28 | int64 isbn = 1; 29 | string title = 2; 30 | string author = 3; 31 | } 32 | 33 | message GetBookRequest { 34 | int64 isbn = 1; 35 | } 36 | 37 | service BookService { 38 | rpc GetBook(GetBookRequest) returns (Book) {} 39 | } 40 | ``` 41 | 42 | Will generate `book_service_pb.js`, `book_service_pb.d.ts`, `book_service_pb_service.js` and `book_service_pb_service.d.ts` 43 | 44 | The first two files contain the message classes while the lst two contain a `BookService.GetBook` class that acts as [method definition](concepts.md#method-definition) that can be used with `@improbable-eng/grpc-web`. 45 | -------------------------------------------------------------------------------- /client/grpc-web/docs/concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | It is assumed that you are familiar with the gRPC concepts found here: https://grpc.io/docs/guides/concepts.html 4 | 5 | This page describes various concepts referred to within the gRPC-Web documentation. 6 | 7 | ### Method Definition 8 | A method definition includes the service and method names, request and response types and whether the method is client-streaming and/or server-streaming. 9 | 10 | Definitions of the format used by `@improbable-eng/grpc-web` can be generated using the [`ts-protoc-gen`](https://github.com/improbable-eng/ts-protoc-gen) plugin for [protoc](https://github.com/google/protobuf). See [code generation](code-generation) for instructions. 11 | 12 | #### Example method definition: 13 | ```javascript 14 | export namespace BookService { 15 | export class GetBook { 16 | static readonly methodName = "GetBook"; 17 | static readonly service = BookService; 18 | static readonly requestStream = false; 19 | static readonly responseStream = false; 20 | static readonly requestType = GetBookRequest; 21 | static readonly responseType = Book; 22 | } 23 | } 24 | ``` 25 | 26 | ### Metadata 27 | Metadata is a collection of key-value pairs sent by the client to the server and then from the server to the client both before the response (headers) and after the response (trailers). One use case for metadata is for sending authentication tokens from a client. 28 | 29 | `@improbable-eng/grpc-web` uses the [`js-browser-headers`](https://github.com/improbable-eng/js-browser-headers) library to provide a consistent implementation of the Headers class across browsers. The `BrowserHeaders` class from this library is aliased to `grpc.Metadata`. 30 | 31 | ### Status Codes 32 | Upon completion a gRPC request will expose a status code indicating how the request ended. This status code can be provided by the server in the [Metadata](#metadata), but if the request failed or the server did not include a status code then the status code is determined by the client. 33 | 34 | `0 - OK` indicates that the request was completed successfully. 35 | 36 | #### `grpc.Code`: 37 | ```javascript 38 | 0 OK 39 | 1 Canceled 40 | 2 Unknown 41 | 3 InvalidArgument 42 | 4 DeadlineExceeded 43 | 5 NotFound 44 | 6 AlreadyExists 45 | 7 PermissionDenied 46 | 8 ResourceExhausted 47 | 9 FailedPrecondition 48 | 10 Aborted 49 | 11 OutOfRange 50 | 12 Unimplemented 51 | 13 Internal 52 | 14 Unavailable 53 | 15 DataLoss 54 | 16 Unauthenticated 55 | ``` 56 | 57 | ### Status Messages 58 | Alongside a status code, requests can include a message upon completion. This can be provided by the server in the [Metadata](#metadata), but if the request failed or the server did not include a status message then the status message is determined by the client and is intended to aid debugging. 59 | 60 | -------------------------------------------------------------------------------- /client/grpc-web/docs/invoke.md: -------------------------------------------------------------------------------- 1 | # grpc.invoke 2 | 3 | `grpc.invoke` allows making either a unary or server-streaming gRPC request. 4 | 5 | There is also [`grpc.unary`](unary.md) which is a convenience function for making unary requests and [`grpc.client`](client.md) for making bi-directional requests. `grpc.client` will also work with unary and server-streaming methods. 6 | 7 | ## API Docs: 8 | ```javascript 9 | grpc.invoke(methodDescriptor: MethodDescriptor, props: InvokeRpcOptions): Request; 10 | ``` 11 | 12 | `methodDescriptor` is a generated method definition ([see code generation for how to generate these](code-generation.md)). 13 | 14 | #### `InvokeRpcOptions`: 15 | 16 | * `host: string` 17 | * The server address (`"https://example.com:9100"`) 18 | * `request: grpc.ProtobufMessage` 19 | * The single request message to send to the server 20 | * `metadata: grpc.Metadata` 21 | * The metadata to send to the server 22 | * `onHeaders: (headers: grpc.Metadata) => void)` 23 | * A callback for headers being received 24 | * `onMessage: (response: grpc.ProtobufMessage) => void)` 25 | * A callback for messages being received 26 | * `onEnd: (code: grpc.Code, message: string, trailers: grpc.Metadata) => void)` 27 | * A callback for the end of the request and trailers being received 28 | * `transport?: TransportFactory` 29 | * (optional) A function to build a `Transport` that will be used for the request. If no transport is specified then a browser-compatible transport will be used. See [transport](transport.md). 30 | * `debug?: boolean` 31 | * (optional) if `true`, debug information will be printed to the console 32 | 33 | #### `Request`: 34 | ```javascript 35 | // Close the connection to the server without waiting for any response 36 | close(): void; 37 | ``` 38 | 39 | ### Lifecycle 40 | A unary or server-streaming gRPC request goes through the following stages: 41 | 42 | * Request with optional metadata and a single message is sent to the server - `invoke()` 43 | * Server sends headers (metadata) - `onHeaders` callback called 44 | * Server responds with one (or more if non-unary) message(s) to the client - `onMessage` callback called 45 | * Server closes the request with [status code](concepts.md#status-codes) and trailers (metadata) - `onEnd` callback called 46 | 47 | ## Example: 48 | ```javascript 49 | const request = new QueryBooksRequest(); 50 | request.setAuthorPrefix("Geor"); 51 | 52 | const grpcRequest = grpc.invoke(BookService.QueryBooks, { 53 | host: "https://example.com:9100", 54 | metadata: new grpc.Metadata({"HeaderTestKey1": "ClientValue1"}), 55 | onHeaders: ((headers: grpc.Metadata) => { 56 | console.log("onHeaders", headers); 57 | }, 58 | onMessage: ((message: Book) => { 59 | console.log("onMessage", message); 60 | }, 61 | onEnd: ((status: grpc.Code, statusMessage: string, trailers: grpc.Metadata) => { 62 | console.log("onEnd", status, statusMessage, trailers); 63 | }, 64 | }); 65 | 66 | grpcRequest.close();// Included as an example of how to close the request, but this usage would cancel the request immediately 67 | ``` -------------------------------------------------------------------------------- /client/grpc-web/docs/transport.md: -------------------------------------------------------------------------------- 1 | # Transports 2 | 3 | `@improbable-eng/grpc-web` is a library for JavaScript-based clients to communicate with servers which can talk the `grpc-web` protocol. 4 | 5 | To enable this communication, `@improbable-eng/grpc-web` uses Transports provides built-in transports which abstract the underlying browser primitives. 6 | 7 | ## Specifying Transports 8 | You can tell `@improbable-eng/grpc-web` which Transport to use either on a per-request basis, or by configuring the Default Transport in your application. 9 | 10 | ### Specifying the Default Transport 11 | The Default Transport is used for every call unless a specific transport has been provided when the call is made. `@improbable-eng/grpc-web` will default to using the `CrossBrowserHttpTransport`, however you can re-configure this: 12 | 13 | ```typescript 14 | import {grpc} from "@improbable-eng/grpc-web"; 15 | 16 | // 'myTransport' is configured to send Browser cookies along with cross-origin requests. 17 | const myTransport = grpc.CrossBrowserHttpTransport({ withCredentials: true }); 18 | 19 | // Specify the default transport before any requests are made. 20 | grpc.setDefaultTransport(myTransport); 21 | ``` 22 | 23 | ### Specifying the Transport on a per-request basis 24 | As mentioned above, `@improbable-eng/grpc-web` will use the Default Transport if none is specified with the call; you can override this behavior by setting the optional `transport` property when calling `unary()`, `invoke()` or `client()`: 25 | 26 | ```typescript 27 | import {grpc} from "@improbable-eng/grpc-web"; 28 | 29 | // 'myTransport' is configured to send Browser cookies along with cross-origin requests. 30 | const myTransport = grpc.CrossBrowserHttpTransport({ withCredentials: true }); 31 | const client = grpc.client(BookService.QueryBooks, { 32 | host: "https://example.com:9100", 33 | transport: myTransport 34 | }); 35 | ``` 36 | 37 | 38 | ## Built-in Transports 39 | `@improbable-eng/grpc-web` ships with two categories of Transports: [HTTP/2-based](#http/2-based-transports) and [socket-based](#socket-based-transports): 40 | 41 | ### HTTP/2-based Transports 42 | It's great that we have more than one choice when it comes to Web Browsers, however the inconsistencies and limited feature-sets can be frustrating. Whilst `@improbable-eng/grpc-web` looks to abstract as much of this as possible with the `CrossBrowserHttpTransport` there are some caveats all application developers who make use of `@improbable-eng/grpc-web`'s HTTP/2 based transports should be aware of: 43 | 44 | * gRPC offers four categories of request: unary, server-streaming, client-streaming and bi-directional. Due to limitations of the Browser HTTP primitives (`fetch` and `XMLHttpRequest`), the HTTP/2-based transports provided by `@improbable-eng/grpc-web` can only support unary and server-streaming requests. Attempts to invoke either client-streaming or bi-directional endpoints will result in failure. 45 | * Older versions of Safari (<7) and all versions of Internet Explorer do not provide an efficient way to stream data from a server; this will result in the entire response of a gRPC client-stream being buffered into memory which can cause performance and stability issues for end-users. 46 | * Microsoft Edge (<16) does not propagate the cancellation of requests to the server; which can result in memory/process leaks on your server. Track [this issue](https://github.com/improbable-eng/grpc-web/issues/125) for status. 47 | 48 | Note that the [Socket-based Transports](#socket-based-transports) alleviate the above issues. 49 | 50 | #### CrossBrowserHttpTransport 51 | The `CrossBrowserHttpTransport` is a compatibility layer which abstracts the concrete HTTP/2 based Transports described below. This is the preferred Transport if you need to support a wide-range of browsers. As it represents the greatest common divisor of the fetch and xhr-based transports, configuration is limited: 52 | 53 | ```typescript 54 | interface CrossBrowserHttpTransportInit { 55 | // send browser cookies along with cross-origin requests (CORS), defaults to `false`. 56 | withCredentials?: boolean 57 | } 58 | ``` 59 | 60 | The `CrossBrowserHttpTransport` will automatically select a concrete HTTP/2 based transport based on the capabilities exposed by the user's browser. 61 | 62 | #### FetchReadableStreamTransport 63 | The `FetchReadableStreamTransport` is a concrete HTTP/2 based transport which uses the `fetch` browser primitive; whilst the `fetch` API is now widely available in modern browsers, [support for request ReadableStreams](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/ReadableStream#Browser_compatibility), required for implementing efficient server-streaming, is still inconsistent. This transport allows full configuration of the fetch request mirroring the [`RequestInit` browser API](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request), sans the ability to specify `headers`, `method`, `body` or `signal` properties to avoid conflicts with the gRPC protocol/implementation. 64 | 65 | ```typescript 66 | interface FetchTransportInit { 67 | // send browser cookies along with requests, defaults to 'omit'. 68 | credentials?: 'omit' | 'same-origin' | 'include' 69 | } 70 | ``` 71 | 72 | #### XhrTransport 73 | The `XhrTransport` is a concrete HTTP/2 based transport which uses the `XMLHttpRequest` browser primitive; there is almost never any need to use this transport directly. Configuration is minimal with no ability to set request headers to avoid conflicts with the gRPC protocol/implementation. 74 | 75 | ```typescript 76 | interface XhrTransportInit { 77 | // send browser cookies along with cross-origin requests (CORS), defaults to `false`. 78 | withCredentials?: boolean 79 | } 80 | ``` 81 | 82 | The `XhrTransport` will automatically detect the Firefox Browser v21+ and make use of the `moz-chunked-arraybuffer` feature which provides enables efficient server-streaming. All other browsers will fall-back to buffering the entire response in memory for the lifecycle of the stream (see [known limitations of HTTP/2-based transports](#http/2-based-transports)). 83 | 84 | ### Socket-based Transports 85 | Browser based HTTP/2 transports have a number of limitations and caveats. We can work around all of these, including support for client-streams and bi-directional streams, by utilising the browser's native [`WebSocket` API](). Note that the `grpc-web-proxy` must be [configured to enable WebSocket support](../../../go/grpcwebproxy/README.md#enabling-websocket-transport). 86 | 87 | ## Alternative Transports 88 | Custom transports can be created by implementing the `Transport` interface; the following transports exist as npm packages which you can import and make use of: 89 | 90 | * [@improbable-eng/grpc-web-node-http-transport](https://www.npmjs.com/package/@improbable-eng/grpc-web-node-http-transport) - Enables the use of grpc-web in NodeJS (ie: non-browser) environments. 91 | -------------------------------------------------------------------------------- /client/grpc-web/docs/unary.md: -------------------------------------------------------------------------------- 1 | # grpc.unary 2 | 3 | `grpc.unary` allows making unary gRPC requests. 4 | 5 | There is also [`grpc.invoke`](invoke.md) for making server-streaming requests and [`grpc.client`](client.md) for making bi-directional requests that will both work with unary methods. 6 | 7 | ## API Docs: 8 | ```javascript 9 | grpc.unary(methodDescriptor: MethodDescriptor, props: UnaryRpcOptions): Request; 10 | ``` 11 | 12 | `methodDescriptor` is a generated method definition ([see code generation for how to generate these](code-generation.md)). 13 | 14 | #### `UnaryRpcOptions`: 15 | 16 | * `host: string` 17 | * The server address (`"https://example.com:9100"`) 18 | * `request: grpc.ProtobufMessage` 19 | * The single request message to send to the server 20 | * `metadata: grpc.Metadata` 21 | * The metadata to send to the server 22 | * `onEnd: (output: UnaryOutput) => void)` 23 | * A callback for the end of the request and trailers being received 24 | * `transport?: TransportFactory` 25 | * (optional) A function to build a `Transport` that will be used for the request. If no transport is specified then a browser-compatible transport will be used. See [transport](transport.md). 26 | * `debug?: boolean` 27 | * (optional) if `true`, debug information will be printed to the console 28 | 29 | #### `UnaryOutput` 30 | 31 | * `status: Code` 32 | * The [status code](concepts.md#status-codes) that the request ended with 33 | * `statusMessage: string` 34 | * The [status message](concepts.md#status-messages) that the request ended with 35 | * `headers: Metadata` 36 | * The headers ([Metadata](concepts.md#metadata)) that the server sent 37 | * `message: TResponse | null` 38 | * The single message that the server sent in the response. 39 | * `trailers: Metadata` 40 | * The trailers ([Metadata](concepts.md#metadata)) that the server sent 41 | 42 | #### `Request`: 43 | ```javascript 44 | // Close the connection to the server without waiting for any response 45 | close(): void; 46 | ``` 47 | 48 | ### Lifecycle 49 | A unary gRPC request goes through the following stages: 50 | 51 | * Request with optional metadata and a single message is sent to the server - `unary()` 52 | * Server sends headers (metadata) 53 | * Server responds with one message to the client 54 | * Server closes the request with status code and trailers (metadata) - `onEnd` callback called with the message and metadata that was received 55 | 56 | ## Example: 57 | ```javascript 58 | const request = new QueryBooksRequest(); 59 | request.setAuthorPrefix("Geor"); 60 | 61 | const grpcRequest = grpc.unary(BookService.QueryBooks, { 62 | host: "https://example.com:9100", 63 | metadata: new grpc.Metadata({"HeaderTestKey1": "ClientValue1"}), 64 | onEnd: ({status, statusMessage, headers, message, trailers: string, trailers: grpc.Metadata}) => { 65 | console.log("onEnd", status, statusMessage, headers, message, trailers); 66 | }, 67 | request, 68 | }); 69 | 70 | grpcRequest.close();// Included as an example of how to close the request, but this usage would cancel the request immediately 71 | ``` 72 | -------------------------------------------------------------------------------- /client/grpc-web/docs/websocket.md: -------------------------------------------------------------------------------- 1 | # Client-side streaming over websocket 2 | 3 | Due to the [limitations](./transport.md#http2-based-transports) of HTTP/2 based transports in browsers, they cannot support client-side/bi-directional streaming. `@improbable-eng/grpc-web` provides a built-in [websocket](./transport.md#socket-based-transports) transport that alleviates this issue. 4 | 5 | ## Enabling at the client side 6 | 7 | To enable websocket communication at the client side, `WebsocketTransport` needs to be configured. See [this](./transport.md#specifying-transports) on how to configure a transport in your application. 8 | 9 | ## Enabling at the server side 10 | 11 | ### grpcwebproxy 12 | 13 | If you're using `grpcwebproxy` to front your gRPC server, see [this](../../../go/grpcwebproxy/README.md#enabling-websocket-transport). 14 | 15 | ### grpcweb 16 | 17 | If you're using `grpcweb` module as a wrapper around your gRPC-Go server, see [this](../../../go/grpcweb#func--withwebsockets). 18 | -------------------------------------------------------------------------------- /client/grpc-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@improbable-eng/grpc-web", 3 | "version": "0.15.0", 4 | "description": "gRPC-Web client for browsers (JS/TS)", 5 | "main": "dist/grpc-web-client.js", 6 | "browser": "dist/grpc-web-client.umd.js", 7 | "types": "dist/typings/index.d.ts", 8 | "scripts": { 9 | "clean": "rm -rf dist", 10 | "postbootstrap": "npm run lib:build", 11 | "lib:build": "npm run clean && webpack" 12 | }, 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "author": "Improbable", 17 | "license": "Apache-2.0", 18 | "repository": { 19 | "type": "git", 20 | "url": "github.com/improbable-eng/grpc-web" 21 | }, 22 | "keywords": [ 23 | "grpc", 24 | "grpc-web", 25 | "protobuf", 26 | "typescript", 27 | "ts" 28 | ], 29 | "files": [ 30 | "dist" 31 | ], 32 | "peerDependencies": { 33 | "google-protobuf": "^3.14.0" 34 | }, 35 | "dependencies": { 36 | "browser-headers": "^0.4.1" 37 | }, 38 | "devDependencies": { 39 | "@types/google-protobuf": "^3.7.4", 40 | "@types/node": "^14.14.22", 41 | "google-protobuf": "^3.14.0", 42 | "ts-loader": "^8.0.14", 43 | "typescript": "4.1.3", 44 | "webpack": "^5.19.0", 45 | "webpack-cli": "^4.4.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/grpc-web/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | VERSION=${1} 5 | if [ -z ${VERSION} ]; then 6 | echo "VERSION not set" 7 | exit 1 8 | fi 9 | 10 | TAG=${2} 11 | if [ -z ${TAG} ]; then 12 | echo "TAG not set (e.g.\"latest\" or \"beta\")" 13 | exit 1 14 | fi 15 | 16 | if [[ `git status --porcelain` ]]; then 17 | echo "There are pending changes, refusing to release." 18 | exit 1 19 | fi 20 | 21 | read -p "Release v${VERSION} with tag ${TAG}? " -n 1 -r 22 | echo 23 | if [[ $REPLY =~ ^[Yy]$ ]] 24 | then 25 | echo "Building standalone artifact" 26 | npm run lib:build 27 | 28 | echo "Staring npm publish" 29 | npm publish --access public --tag $TAG 30 | 31 | echo "Creating Github release branch release/v${VERSION}" 32 | git checkout -b release/v${VERSION} 33 | git add . 34 | git commit -m "Release ${VERSION}" 35 | git tag v${VERSION} 36 | git push origin --tags 37 | 38 | echo "All done!" 39 | fi 40 | -------------------------------------------------------------------------------- /client/grpc-web/src/ChunkParser.ts: -------------------------------------------------------------------------------- 1 | import {Metadata} from "./metadata"; 2 | 3 | const HEADER_SIZE = 5; 4 | 5 | const isAllowedControlChars = (char: number) => char === 0x9 || char === 0xa || char === 0xd; 6 | 7 | function isValidHeaderAscii(val: number): boolean { 8 | return isAllowedControlChars(val) || (val >= 0x20 && val <= 0x7e); 9 | } 10 | 11 | export function decodeASCII(input: Uint8Array): string { 12 | // With ES2015, TypedArray.prototype.every can be used 13 | for (let i = 0; i !== input.length; ++i) { 14 | if (!isValidHeaderAscii(input[i])) { 15 | throw new Error("Metadata is not valid (printable) ASCII"); 16 | } 17 | } 18 | // With ES2017, the array conversion can be omitted with iterables 19 | return String.fromCharCode(...Array.prototype.slice.call(input)); 20 | } 21 | 22 | export function encodeASCII(input: string): Uint8Array { 23 | const encoded = new Uint8Array(input.length); 24 | for (let i = 0; i !== input.length; ++i) { 25 | const charCode = input.charCodeAt(i); 26 | if (!isValidHeaderAscii(charCode)) { 27 | throw new Error("Metadata contains invalid ASCII"); 28 | } 29 | encoded[i] = charCode; 30 | } 31 | return encoded; 32 | } 33 | 34 | function isTrailerHeader(headerView: DataView) { 35 | // This is encoded in the MSB of the grpc header's first byte. 36 | return (headerView.getUint8(0) & 0x80) === 0x80 37 | } 38 | 39 | function parseTrailerData(msgData: Uint8Array): Metadata { 40 | return new Metadata(decodeASCII(msgData)) 41 | } 42 | 43 | function readLengthFromHeader(headerView: DataView) { 44 | return headerView.getUint32(1, false) 45 | } 46 | 47 | function hasEnoughBytes(buffer: Uint8Array, position: number, byteCount: number) { 48 | return buffer.byteLength - position >= byteCount; 49 | } 50 | 51 | function sliceUint8Array(buffer: Uint8Array, from: number, to?: number) { 52 | if (buffer.slice) { 53 | return buffer.slice(from, to); 54 | } 55 | 56 | let end = buffer.length; 57 | if (to !== undefined) { 58 | end = to; 59 | } 60 | 61 | const num = end - from; 62 | const array = new Uint8Array(num); 63 | let arrayIndex = 0; 64 | for (let i = from; i < end; i++) { 65 | array[arrayIndex++] = buffer[i]; 66 | } 67 | return array; 68 | } 69 | 70 | export enum ChunkType { 71 | MESSAGE = 1, 72 | TRAILERS = 2, 73 | } 74 | 75 | export type Chunk = { 76 | chunkType: ChunkType, 77 | trailers?: Metadata, 78 | data?: Uint8Array, 79 | } 80 | 81 | export class ChunkParser { 82 | buffer: Uint8Array | null = null; 83 | position: number = 0; 84 | 85 | parse(bytes: Uint8Array, flush?: boolean): Chunk[] { 86 | if (bytes.length === 0 && flush) { 87 | return []; 88 | } 89 | 90 | const chunkData: Chunk[] = []; 91 | 92 | if (this.buffer == null) { 93 | this.buffer = bytes; 94 | this.position = 0; 95 | } else if (this.position === this.buffer.byteLength) { 96 | this.buffer = bytes; 97 | this.position = 0; 98 | } else { 99 | const remaining = this.buffer.byteLength - this.position; 100 | const newBuf = new Uint8Array(remaining + bytes.byteLength); 101 | const fromExisting = sliceUint8Array(this.buffer, this.position); 102 | newBuf.set(fromExisting, 0); 103 | const latestDataBuf = new Uint8Array(bytes); 104 | newBuf.set(latestDataBuf, remaining); 105 | this.buffer = newBuf; 106 | this.position = 0; 107 | } 108 | 109 | while (true) { 110 | if (!hasEnoughBytes(this.buffer, this.position, HEADER_SIZE)) { 111 | return chunkData; 112 | } 113 | 114 | let headerBuffer = sliceUint8Array(this.buffer, this.position, this.position + HEADER_SIZE); 115 | 116 | const headerView = new DataView(headerBuffer.buffer, headerBuffer.byteOffset, headerBuffer.byteLength); 117 | 118 | const msgLength = readLengthFromHeader(headerView); 119 | if (!hasEnoughBytes(this.buffer, this.position, HEADER_SIZE + msgLength)) { 120 | return chunkData; 121 | } 122 | 123 | const messageData = sliceUint8Array(this.buffer, this.position + HEADER_SIZE, this.position + HEADER_SIZE + msgLength); 124 | this.position += HEADER_SIZE + msgLength; 125 | 126 | if (isTrailerHeader(headerView)) { 127 | chunkData.push({chunkType: ChunkType.TRAILERS, trailers: parseTrailerData(messageData)}); 128 | return chunkData; 129 | } else { 130 | chunkData.push({chunkType: ChunkType.MESSAGE, data: messageData}) 131 | } 132 | } 133 | } 134 | } 135 | 136 | -------------------------------------------------------------------------------- /client/grpc-web/src/Code.ts: -------------------------------------------------------------------------------- 1 | export enum Code { 2 | OK = 0, 3 | Canceled = 1, 4 | Unknown = 2, 5 | InvalidArgument = 3, 6 | DeadlineExceeded = 4, 7 | NotFound = 5, 8 | AlreadyExists = 6, 9 | PermissionDenied = 7, 10 | ResourceExhausted = 8, 11 | FailedPrecondition = 9, 12 | Aborted = 10, 13 | OutOfRange = 11, 14 | Unimplemented = 12, 15 | Internal = 13, 16 | Unavailable = 14, 17 | DataLoss = 15, 18 | Unauthenticated = 16, 19 | } 20 | 21 | export function httpStatusToCode(httpStatus: number): Code { 22 | switch (httpStatus) { 23 | case 0: // Connectivity issues 24 | return Code.Internal; 25 | case 200: 26 | return Code.OK; 27 | case 400: 28 | return Code.InvalidArgument; 29 | case 401: 30 | return Code.Unauthenticated; 31 | case 403: 32 | return Code.PermissionDenied; 33 | case 404: 34 | return Code.NotFound; 35 | case 409: 36 | return Code.Aborted; 37 | case 412: 38 | return Code.FailedPrecondition; 39 | case 429: 40 | return Code.ResourceExhausted; 41 | case 499: 42 | return Code.Canceled; 43 | case 500: 44 | return Code.Unknown; 45 | case 501: 46 | return Code.Unimplemented; 47 | case 503: 48 | return Code.Unavailable; 49 | case 504: 50 | return Code.DeadlineExceeded; 51 | default: 52 | return Code.Unknown; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/grpc-web/src/debug.ts: -------------------------------------------------------------------------------- 1 | export function debug(...args: any[]) { 2 | if (console.debug) { 3 | console.debug.apply(null, args); 4 | } else { 5 | console.log.apply(null, args); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/grpc-web/src/detach.ts: -------------------------------------------------------------------------------- 1 | export default function detach(cb: () => void) { 2 | cb(); 3 | } 4 | -------------------------------------------------------------------------------- /client/grpc-web/src/index.ts: -------------------------------------------------------------------------------- 1 | import {BrowserHeaders} from "browser-headers"; 2 | import * as impTransport from "./transports/Transport"; 3 | import * as impTransportFetch from "./transports/http/fetch"; 4 | import * as impTransportWebSocket from "./transports/websocket/websocket"; 5 | import * as impTransportXhr from "./transports/http/xhr"; 6 | import * as impTransportHttp from "./transports/http/http"; 7 | import * as impCode from "./Code"; 8 | import * as impInvoke from "./invoke"; 9 | import * as impUnary from "./unary"; 10 | import * as impClient from "./client"; 11 | import * as impService from "./service"; 12 | import * as impMessage from "./message"; 13 | 14 | export namespace grpc { 15 | export interface ProtobufMessageClass extends impMessage.ProtobufMessageClass {} 16 | export interface ProtobufMessage extends impMessage.ProtobufMessage {} 17 | 18 | export interface Transport extends impTransport.Transport {} 19 | export interface TransportOptions extends impTransport.TransportOptions {} 20 | export interface TransportFactory extends impTransport.TransportFactory {} 21 | export const setDefaultTransport = impTransport.setDefaultTransportFactory; 22 | 23 | export const CrossBrowserHttpTransport = impTransportHttp.CrossBrowserHttpTransport; 24 | export interface CrossBrowserHttpTransportInit extends impTransportHttp.CrossBrowserHttpTransportInit {} 25 | 26 | export const FetchReadableStreamTransport = impTransportFetch.FetchReadableStreamTransport; 27 | export interface FetchReadableStreamInit extends impTransportFetch.FetchTransportInit {} 28 | 29 | export const XhrTransport = impTransportXhr.XhrTransport; 30 | export interface XhrTransportInit extends impTransportXhr.XhrTransportInit {} 31 | 32 | export const WebsocketTransport = impTransportWebSocket.WebsocketTransport; 33 | 34 | export interface UnaryMethodDefinition extends impService.UnaryMethodDefinition {} 35 | export interface MethodDefinition extends impService.MethodDefinition {} 36 | export interface ServiceDefinition extends impService.ServiceDefinition {} 37 | 38 | export import Code = impCode.Code; 39 | export import Metadata = BrowserHeaders; 40 | 41 | export interface Client extends impClient.Client {} 42 | export function client>(methodDescriptor: M, props: ClientRpcOptions): Client { 43 | return impClient.client(methodDescriptor, props); 44 | } 45 | export interface RpcOptions extends impClient.RpcOptions {} 46 | export interface ClientRpcOptions extends impClient.ClientRpcOptions {} 47 | 48 | export const invoke = impInvoke.invoke; 49 | export interface Request extends impInvoke.Request {} 50 | export interface InvokeRpcOptions extends impInvoke.InvokeRpcOptions {} 51 | 52 | export const unary = impUnary.unary; 53 | export interface UnaryOutput extends impUnary.UnaryOutput {} 54 | export interface UnaryRpcOptions extends impUnary.UnaryRpcOptions {} 55 | } 56 | -------------------------------------------------------------------------------- /client/grpc-web/src/invoke.ts: -------------------------------------------------------------------------------- 1 | import {Code} from "./Code"; 2 | import {MethodDefinition} from "./service"; 3 | import {Metadata} from "./metadata"; 4 | import {client, RpcOptions} from "./client"; 5 | import {ProtobufMessage} from "./message"; 6 | 7 | export interface Request { 8 | close: () => void; 9 | } 10 | 11 | export interface InvokeRpcOptions extends RpcOptions { 12 | host: string; 13 | request: TRequest; 14 | metadata?: Metadata.ConstructorArg; 15 | onHeaders?: (headers: Metadata) => void; 16 | onMessage?: (res: TResponse) => void; 17 | onEnd: (code: Code, message: string, trailers: Metadata) => void; 18 | } 19 | 20 | 21 | export function invoke>(methodDescriptor: M, props: InvokeRpcOptions): Request { 22 | if (methodDescriptor.requestStream) { 23 | throw new Error(".invoke cannot be used with client-streaming methods. Use .client instead."); 24 | } 25 | 26 | // client can throw an error if the transport factory returns an error (e.g. no valid transport) 27 | const grpcClient = client(methodDescriptor, { 28 | host: props.host, 29 | transport: props.transport, 30 | debug: props.debug, 31 | }); 32 | 33 | if (props.onHeaders) { 34 | grpcClient.onHeaders(props.onHeaders); 35 | } 36 | if (props.onMessage) { 37 | grpcClient.onMessage(props.onMessage); 38 | } 39 | if (props.onEnd) { 40 | grpcClient.onEnd(props.onEnd); 41 | } 42 | 43 | grpcClient.start(props.metadata); 44 | grpcClient.send(props.request); 45 | grpcClient.finishSend(); 46 | 47 | return { 48 | close: () => { 49 | grpcClient.close(); 50 | } 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /client/grpc-web/src/message.ts: -------------------------------------------------------------------------------- 1 | export interface ProtobufMessageClass { 2 | new(): T; 3 | deserializeBinary(bytes: Uint8Array): T; 4 | } 5 | 6 | export interface ProtobufMessage { 7 | toObject(): {}; 8 | serializeBinary(): Uint8Array; 9 | } 10 | -------------------------------------------------------------------------------- /client/grpc-web/src/metadata.ts: -------------------------------------------------------------------------------- 1 | import {BrowserHeaders} from "browser-headers"; 2 | 3 | export {BrowserHeaders as Metadata}; 4 | -------------------------------------------------------------------------------- /client/grpc-web/src/service.ts: -------------------------------------------------------------------------------- 1 | import {ProtobufMessage, ProtobufMessageClass} from "./message"; 2 | 3 | export interface ServiceDefinition { 4 | serviceName: string; 5 | } 6 | 7 | export interface MethodDefinition { 8 | methodName: string; 9 | service: ServiceDefinition; 10 | requestStream: boolean; 11 | responseStream: boolean; 12 | requestType: ProtobufMessageClass; 13 | responseType: ProtobufMessageClass; 14 | } 15 | 16 | export interface UnaryMethodDefinition extends MethodDefinition { 17 | requestStream: false; 18 | responseStream: false; 19 | } 20 | -------------------------------------------------------------------------------- /client/grpc-web/src/transports/Transport.ts: -------------------------------------------------------------------------------- 1 | import {Metadata} from "../metadata"; 2 | import {MethodDefinition} from "../service"; 3 | import {ProtobufMessage} from "../message"; 4 | import {CrossBrowserHttpTransport} from "./http/http"; 5 | 6 | export interface Transport { 7 | sendMessage(msgBytes: Uint8Array): void 8 | finishSend(): void 9 | cancel(): void 10 | start(metadata: Metadata): void 11 | } 12 | 13 | let defaultTransportFactory: TransportFactory = options => CrossBrowserHttpTransport({ withCredentials: false })(options); 14 | 15 | export function setDefaultTransportFactory(t: TransportFactory): void { 16 | defaultTransportFactory = t; 17 | } 18 | 19 | export function makeDefaultTransport(options: TransportOptions): Transport { 20 | return defaultTransportFactory(options); 21 | } 22 | 23 | export interface TransportOptions { 24 | methodDefinition: MethodDefinition; 25 | debug: boolean; 26 | url: string; 27 | onHeaders: (headers: Metadata, status: number) => void; 28 | onChunk: (chunkBytes: Uint8Array, flush?: boolean) => void; 29 | onEnd: (err?: Error) => void; 30 | } 31 | 32 | export interface TransportFactory { 33 | (options: TransportOptions): Transport; 34 | } 35 | -------------------------------------------------------------------------------- /client/grpc-web/src/transports/http/fetch.ts: -------------------------------------------------------------------------------- 1 | import {Metadata} from "../../metadata"; 2 | import {Transport, TransportFactory, TransportOptions} from "../Transport"; 3 | import {debug} from "../../debug"; 4 | 5 | type Omit = Pick> 6 | export type FetchTransportInit = Omit; 7 | 8 | export function FetchReadableStreamTransport(init: FetchTransportInit): TransportFactory { 9 | return (opts: TransportOptions) => { 10 | return fetchRequest(opts, init); 11 | } 12 | } 13 | 14 | function fetchRequest(options: TransportOptions, init: FetchTransportInit): Transport { 15 | options.debug && debug("fetchRequest", options); 16 | return new Fetch(options, init); 17 | } 18 | 19 | declare const Response: any; 20 | declare const Headers: any; 21 | 22 | class Fetch implements Transport { 23 | cancelled: boolean = false; 24 | options: TransportOptions; 25 | init: FetchTransportInit; 26 | reader: ReadableStreamReader; 27 | metadata: Metadata; 28 | controller: AbortController | undefined = (self as any).AbortController && new AbortController(); 29 | 30 | constructor(transportOptions: TransportOptions, init: FetchTransportInit) { 31 | this.options = transportOptions; 32 | this.init = init; 33 | } 34 | 35 | pump(readerArg: ReadableStreamReader, res: Response) { 36 | this.reader = readerArg; 37 | if (this.cancelled) { 38 | // If the request was cancelled before the first pump then cancel it here 39 | this.options.debug && debug("Fetch.pump.cancel at first pump"); 40 | this.reader.cancel().catch(e => { 41 | // This can be ignored. It will likely throw an exception due to the request being aborted 42 | this.options.debug && debug("Fetch.pump.reader.cancel exception", e); 43 | }); 44 | return; 45 | } 46 | this.reader.read() 47 | .then((result: { done: boolean, value: Uint8Array }) => { 48 | if (result.done) { 49 | this.options.onEnd(); 50 | return res; 51 | } 52 | this.options.onChunk(result.value); 53 | this.pump(this.reader, res); 54 | return; 55 | }) 56 | .catch(err => { 57 | if (this.cancelled) { 58 | this.options.debug && debug("Fetch.catch - request cancelled"); 59 | return; 60 | } 61 | this.cancelled = true; 62 | this.options.debug && debug("Fetch.catch", err.message); 63 | this.options.onEnd(err); 64 | }); 65 | } 66 | 67 | send(msgBytes: Uint8Array) { 68 | fetch(this.options.url, { 69 | ...this.init, 70 | headers: this.metadata.toHeaders(), 71 | method: "POST", 72 | body: msgBytes, 73 | signal: this.controller && this.controller.signal, 74 | }).then((res: Response) => { 75 | this.options.debug && debug("Fetch.response", res); 76 | this.options.onHeaders(new Metadata(res.headers as any), res.status); 77 | if (res.body) { 78 | this.pump(res.body.getReader(), res) 79 | return; 80 | } 81 | return res; 82 | }).catch(err => { 83 | if (this.cancelled) { 84 | this.options.debug && debug("Fetch.catch - request cancelled"); 85 | return; 86 | } 87 | this.cancelled = true; 88 | this.options.debug && debug("Fetch.catch", err.message); 89 | this.options.onEnd(err); 90 | }); 91 | } 92 | 93 | sendMessage(msgBytes: Uint8Array) { 94 | this.send(msgBytes); 95 | } 96 | 97 | finishSend() { 98 | 99 | } 100 | 101 | start(metadata: Metadata) { 102 | this.metadata = metadata; 103 | } 104 | 105 | cancel() { 106 | if (this.cancelled) { 107 | this.options.debug && debug("Fetch.cancel already cancelled"); 108 | return; 109 | } 110 | this.cancelled = true; 111 | 112 | if (this.controller) { 113 | this.options.debug && debug("Fetch.cancel.controller.abort"); 114 | this.controller.abort(); 115 | } else { 116 | this.options.debug && debug("Fetch.cancel.missing abort controller"); 117 | } 118 | 119 | if (this.reader) { 120 | // If the reader has already been received in the pump then it can be cancelled immediately 121 | this.options.debug && debug("Fetch.cancel.reader.cancel"); 122 | this.reader.cancel().catch(e => { 123 | // This can be ignored. It will likely throw an exception due to the request being aborted 124 | this.options.debug && debug("Fetch.cancel.reader.cancel exception", e); 125 | }); 126 | } else { 127 | this.options.debug && debug("Fetch.cancel before reader"); 128 | } 129 | } 130 | } 131 | 132 | export function detectFetchSupport(): boolean { 133 | return typeof Response !== "undefined" && Response.prototype.hasOwnProperty("body") && typeof Headers === "function"; 134 | } 135 | -------------------------------------------------------------------------------- /client/grpc-web/src/transports/http/http.ts: -------------------------------------------------------------------------------- 1 | import {TransportFactory} from "../Transport"; 2 | import {detectFetchSupport, FetchReadableStreamTransport, FetchTransportInit} from "./fetch"; 3 | import {XhrTransport} from "./xhr"; 4 | 5 | export interface CrossBrowserHttpTransportInit { 6 | withCredentials?: boolean 7 | } 8 | 9 | export function CrossBrowserHttpTransport(init: CrossBrowserHttpTransportInit): TransportFactory { 10 | if (detectFetchSupport()) { 11 | const fetchInit: FetchTransportInit = { 12 | credentials: init.withCredentials ? "include" : "same-origin" 13 | }; 14 | return FetchReadableStreamTransport(fetchInit); 15 | } 16 | return XhrTransport({ withCredentials: init.withCredentials }); 17 | } 18 | -------------------------------------------------------------------------------- /client/grpc-web/src/transports/http/xhr.ts: -------------------------------------------------------------------------------- 1 | import {Metadata} from "../../metadata"; 2 | import {Transport, TransportFactory, TransportOptions} from "../Transport"; 3 | import {debug} from "../../debug"; 4 | import {detectMozXHRSupport, detectXHROverrideMimeTypeSupport} from "./xhrUtil"; 5 | 6 | export interface XhrTransportInit { 7 | withCredentials?: boolean 8 | } 9 | 10 | export function XhrTransport(init: XhrTransportInit): TransportFactory { 11 | return (opts: TransportOptions) => { 12 | if (detectMozXHRSupport()) { 13 | return new MozChunkedArrayBufferXHR(opts, init); 14 | } else if (detectXHROverrideMimeTypeSupport()) { 15 | return new XHR(opts, init); 16 | } else { 17 | throw new Error("This environment's XHR implementation cannot support binary transfer."); 18 | } 19 | } 20 | } 21 | 22 | export class XHR implements Transport { 23 | options: TransportOptions; 24 | init: XhrTransportInit; 25 | xhr: XMLHttpRequest; 26 | metadata: Metadata; 27 | index: 0; 28 | 29 | constructor(transportOptions: TransportOptions, init: XhrTransportInit) { 30 | this.options = transportOptions; 31 | this.init = init; 32 | } 33 | 34 | onProgressEvent() { 35 | this.options.debug && debug("XHR.onProgressEvent.length: ", this.xhr.response.length); 36 | const rawText = this.xhr.response.substr(this.index); 37 | this.index = this.xhr.response.length; 38 | const asArrayBuffer = stringToArrayBuffer(rawText); 39 | this.options.onChunk(asArrayBuffer); 40 | } 41 | 42 | onLoadEvent() { 43 | this.options.debug && debug("XHR.onLoadEvent"); 44 | this.options.onEnd(); 45 | } 46 | 47 | onStateChange() { 48 | this.options.debug && debug("XHR.onStateChange", this.xhr.readyState); 49 | if (this.xhr.readyState === XMLHttpRequest.HEADERS_RECEIVED) { 50 | this.options.onHeaders(new Metadata(this.xhr.getAllResponseHeaders()), this.xhr.status); 51 | } 52 | } 53 | 54 | sendMessage(msgBytes: Uint8Array) { 55 | this.xhr.send(msgBytes); 56 | } 57 | 58 | finishSend() { 59 | } 60 | 61 | start(metadata: Metadata) { 62 | this.metadata = metadata; 63 | 64 | const xhr = new XMLHttpRequest(); 65 | this.xhr = xhr; 66 | xhr.open("POST", this.options.url); 67 | 68 | this.configureXhr(); 69 | 70 | this.metadata.forEach((key, values) => { 71 | xhr.setRequestHeader(key, values.join(", ")); 72 | }); 73 | 74 | xhr.withCredentials = Boolean(this.init.withCredentials); 75 | 76 | xhr.addEventListener("readystatechange", this.onStateChange.bind(this)); 77 | xhr.addEventListener("progress", this.onProgressEvent.bind(this)); 78 | xhr.addEventListener("loadend", this.onLoadEvent.bind(this)); 79 | xhr.addEventListener("error", (err: ErrorEvent) => { 80 | this.options.debug && debug("XHR.error", err); 81 | this.options.onEnd(err.error); 82 | }); 83 | } 84 | 85 | protected configureXhr(): void { 86 | this.xhr.responseType = "text"; 87 | 88 | // overriding the mime type causes a response that has a code point per byte, which can be decoded using the 89 | // stringToArrayBuffer function. 90 | this.xhr.overrideMimeType("text/plain; charset=x-user-defined"); 91 | } 92 | 93 | cancel() { 94 | this.options.debug && debug("XHR.abort"); 95 | this.xhr.abort(); 96 | } 97 | } 98 | 99 | export class MozChunkedArrayBufferXHR extends XHR { 100 | protected configureXhr(): void { 101 | this.options.debug && debug("MozXHR.configureXhr: setting responseType to 'moz-chunked-arraybuffer'"); 102 | (this.xhr as any).responseType = "moz-chunked-arraybuffer"; 103 | } 104 | 105 | onProgressEvent() { 106 | const resp = this.xhr.response; 107 | this.options.debug && debug("MozXHR.onProgressEvent: ", new Uint8Array(resp)); 108 | this.options.onChunk(new Uint8Array(resp)); 109 | } 110 | } 111 | 112 | function codePointAtPolyfill(str: string, index: number) { 113 | let code = str.charCodeAt(index); 114 | if (code >= 0xd800 && code <= 0xdbff) { 115 | const surr = str.charCodeAt(index + 1); 116 | if (surr >= 0xdc00 && surr <= 0xdfff) { 117 | code = 0x10000 + ((code - 0xd800) << 10) + (surr - 0xdc00); 118 | } 119 | } 120 | return code; 121 | } 122 | 123 | export function stringToArrayBuffer(str: string): Uint8Array { 124 | const asArray = new Uint8Array(str.length); 125 | let arrayIndex = 0; 126 | for (let i = 0; i < str.length; i++) { 127 | const codePoint = (String.prototype as any).codePointAt ? (str as any).codePointAt(i) : codePointAtPolyfill(str, i); 128 | asArray[arrayIndex++] = codePoint & 0xFF; 129 | } 130 | return asArray; 131 | } 132 | 133 | -------------------------------------------------------------------------------- /client/grpc-web/src/transports/http/xhrUtil.ts: -------------------------------------------------------------------------------- 1 | let xhr: XMLHttpRequest; 2 | 3 | function getXHR () { 4 | if (xhr !== undefined) return xhr; 5 | 6 | if (XMLHttpRequest) { 7 | xhr = new XMLHttpRequest(); 8 | try { 9 | xhr.open("GET", "https://localhost") 10 | } catch (e) {} 11 | } 12 | return xhr 13 | } 14 | 15 | export function xhrSupportsResponseType(type: string) { 16 | const xhr = getXHR(); 17 | if (!xhr) { 18 | return false; 19 | } 20 | try { 21 | (xhr as any).responseType = type; 22 | return xhr.responseType === type; 23 | } catch (e) {} 24 | return false 25 | } 26 | 27 | export function detectMozXHRSupport(): boolean { 28 | return typeof XMLHttpRequest !== "undefined" && xhrSupportsResponseType("moz-chunked-arraybuffer") 29 | } 30 | 31 | export function detectXHROverrideMimeTypeSupport(): boolean { 32 | return typeof XMLHttpRequest !== "undefined" && XMLHttpRequest.prototype.hasOwnProperty("overrideMimeType") 33 | } -------------------------------------------------------------------------------- /client/grpc-web/src/transports/websocket/websocket.ts: -------------------------------------------------------------------------------- 1 | import {Metadata} from "../../metadata"; 2 | import {Transport, TransportFactory, TransportOptions} from "../Transport"; 3 | import {debug} from "../../debug"; 4 | import {encodeASCII} from "../../ChunkParser"; 5 | 6 | enum WebsocketSignal { 7 | FINISH_SEND = 1 8 | } 9 | 10 | const finishSendFrame = new Uint8Array([1]); 11 | 12 | export function WebsocketTransport(): TransportFactory { 13 | return (opts: TransportOptions) => { 14 | return websocketRequest(opts); 15 | } 16 | } 17 | 18 | function websocketRequest(options: TransportOptions): Transport { 19 | options.debug && debug("websocketRequest", options); 20 | 21 | let webSocketAddress = constructWebSocketAddress(options.url); 22 | 23 | const sendQueue: Array = []; 24 | let ws: WebSocket; 25 | 26 | function sendToWebsocket(toSend: Uint8Array | WebsocketSignal) { 27 | if (toSend === WebsocketSignal.FINISH_SEND) { 28 | ws.send(finishSendFrame); 29 | } else { 30 | const byteArray = toSend as Uint8Array; 31 | const c = new Int8Array(byteArray.byteLength + 1); 32 | c.set(new Uint8Array([0])); 33 | 34 | c.set(byteArray as any as ArrayLike, 1); 35 | 36 | ws.send(c) 37 | } 38 | } 39 | 40 | return { 41 | sendMessage: (msgBytes: Uint8Array) => { 42 | if (!ws || ws.readyState === ws.CONNECTING) { 43 | sendQueue.push(msgBytes); 44 | } else { 45 | sendToWebsocket(msgBytes); 46 | } 47 | }, 48 | finishSend: () => { 49 | if (!ws || ws.readyState === ws.CONNECTING) { 50 | sendQueue.push(WebsocketSignal.FINISH_SEND); 51 | } else { 52 | sendToWebsocket(WebsocketSignal.FINISH_SEND); 53 | } 54 | }, 55 | start: (metadata: Metadata) => { 56 | ws = new WebSocket(webSocketAddress, ["grpc-websockets"]); 57 | ws.binaryType = "arraybuffer"; 58 | ws.onopen = function () { 59 | options.debug && debug("websocketRequest.onopen"); 60 | ws.send(headersToBytes(metadata)); 61 | 62 | // send any messages that were passed to sendMessage before the connection was ready 63 | sendQueue.forEach(toSend => { 64 | sendToWebsocket(toSend); 65 | }); 66 | }; 67 | 68 | ws.onclose = function (closeEvent) { 69 | options.debug && debug("websocketRequest.onclose", closeEvent); 70 | options.onEnd(); 71 | }; 72 | 73 | ws.onerror = function (error) { 74 | options.debug && debug("websocketRequest.onerror", error); 75 | }; 76 | 77 | ws.onmessage = function (e) { 78 | options.onChunk(new Uint8Array(e.data)); 79 | }; 80 | 81 | }, 82 | cancel: () => { 83 | options.debug && debug("websocket.abort"); 84 | ws.close(); 85 | } 86 | }; 87 | } 88 | 89 | function constructWebSocketAddress(url: string) { 90 | if (url.substr(0, 8) === "https://") { 91 | return `wss://${url.substr(8)}`; 92 | } else if (url.substr(0, 7) === "http://") { 93 | return `ws://${url.substr(7)}`; 94 | } 95 | throw new Error("Websocket transport constructed with non-https:// or http:// host."); 96 | } 97 | 98 | function headersToBytes(headers: Metadata): Uint8Array { 99 | let asString = ""; 100 | headers.forEach((key, values) => { 101 | asString += `${key}: ${values.join(", ")}\r\n`; 102 | }); 103 | return encodeASCII(asString); 104 | } 105 | -------------------------------------------------------------------------------- /client/grpc-web/src/unary.ts: -------------------------------------------------------------------------------- 1 | import {Metadata} from "./metadata"; 2 | import {Code} from "./Code"; 3 | import {UnaryMethodDefinition} from "./service"; 4 | import {Request} from "./invoke"; 5 | import {client, RpcOptions} from "./client"; 6 | import {ProtobufMessage} from "./message"; 7 | 8 | export interface UnaryOutput { 9 | status: Code; 10 | statusMessage: string; 11 | headers: Metadata; 12 | message: TResponse | null; 13 | trailers: Metadata; 14 | } 15 | 16 | export interface UnaryRpcOptions extends RpcOptions { 17 | host: string; 18 | request: TRequest; 19 | metadata?: Metadata.ConstructorArg; 20 | onEnd: (output: UnaryOutput) => void; 21 | } 22 | 23 | export function unary>(methodDescriptor: M, props: UnaryRpcOptions): Request { 24 | if (methodDescriptor.responseStream) { 25 | throw new Error(".unary cannot be used with server-streaming methods. Use .invoke or .client instead."); 26 | } 27 | if (methodDescriptor.requestStream) { 28 | throw new Error(".unary cannot be used with client-streaming methods. Use .client instead."); 29 | } 30 | let responseHeaders: Metadata | null = null; 31 | let responseMessage: TResponse | null = null; 32 | 33 | // client can throw an error if the transport factory returns an error (e.g. no valid transport) 34 | const grpcClient = client(methodDescriptor, { 35 | host: props.host, 36 | transport: props.transport, 37 | debug: props.debug, 38 | }); 39 | grpcClient.onHeaders((headers: Metadata) => { 40 | responseHeaders = headers; 41 | }); 42 | grpcClient.onMessage((res: TResponse) => { 43 | responseMessage = res; 44 | }); 45 | grpcClient.onEnd((status: Code, statusMessage: string, trailers: Metadata) => { 46 | props.onEnd({ 47 | status: status, 48 | statusMessage: statusMessage, 49 | headers: responseHeaders ? responseHeaders : new Metadata(), 50 | message: responseMessage, 51 | trailers: trailers 52 | }); 53 | }); 54 | 55 | grpcClient.start(props.metadata); 56 | grpcClient.send(props.request); 57 | grpcClient.finishSend(); 58 | 59 | return { 60 | close: () => { 61 | grpcClient.close(); 62 | } 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /client/grpc-web/src/util.ts: -------------------------------------------------------------------------------- 1 | import {ProtobufMessage} from "./message"; 2 | 3 | export function frameRequest(request: ProtobufMessage): Uint8Array { 4 | const bytes = request.serializeBinary(); 5 | const frame = new ArrayBuffer(bytes.byteLength + 5); 6 | new DataView(frame, 1, 4).setUint32(0, bytes.length, false /* big endian */); 7 | new Uint8Array(frame, 5).set(bytes); 8 | return new Uint8Array(frame); 9 | } 10 | -------------------------------------------------------------------------------- /client/grpc-web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "sourceMap": true, 5 | "declaration": true, 6 | "declarationDir": "./dist/typings", 7 | "target": "es5", 8 | "lib": ["es6","dom"], 9 | "removeComments": true, 10 | "noImplicitReturns": true, 11 | "noImplicitAny": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "strictNullChecks": true, 15 | "stripInternal": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "outDir": "dist", 18 | "noEmitOnError": true 19 | }, 20 | "types": [ 21 | "node" 22 | ], 23 | "include": [ 24 | "src" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /client/grpc-web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const LIB_BASE_CONFIG = { 4 | entry: "./src/index.ts", 5 | mode: "production", 6 | module: { 7 | rules: [{ 8 | test: /\.ts?$/, 9 | use: 'ts-loader', 10 | exclude: /node_modules/ 11 | }] 12 | }, 13 | resolve: { 14 | extensions: ['.ts', '.js'] 15 | }, 16 | }; 17 | const DIST_DIR = path.resolve(__dirname, 'dist'); 18 | 19 | module.exports = [{ 20 | name: 'lib-commonjs', 21 | ...LIB_BASE_CONFIG, 22 | target: 'es5', 23 | output: { 24 | filename: `grpc-web-client.js`, 25 | path: DIST_DIR, 26 | libraryTarget: 'commonjs', 27 | globalObject: 'this', 28 | } 29 | }, 30 | { 31 | name: 'lib-umd', 32 | ...LIB_BASE_CONFIG, 33 | target: 'es5', 34 | output: { 35 | filename: `grpc-web-client.umd.js`, 36 | path: DIST_DIR, 37 | libraryTarget: 'umd', 38 | globalObject: 'this', 39 | } 40 | }, 41 | ]; 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/improbable-eng/grpc-web 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/cenkalti/backoff/v4 v4.1.1 7 | github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f 8 | github.com/golang/protobuf v1.5.2 9 | github.com/grpc-ecosystem/go-grpc-middleware v1.2.2 10 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 11 | github.com/klauspost/compress v1.11.7 // indirect 12 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f 13 | github.com/mwitkow/grpc-proxy v0.0.0-20181017164139-0f1106ef9c76 14 | github.com/prometheus/client_golang v1.12.1 15 | github.com/rs/cors v1.7.0 16 | github.com/sirupsen/logrus v1.7.0 17 | github.com/spf13/pflag v1.0.5 18 | github.com/stretchr/testify v1.7.0 19 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b 20 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect 21 | google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506 // indirect 22 | google.golang.org/grpc v1.32.0 23 | google.golang.org/protobuf v1.27.1 24 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 25 | nhooyr.io/websocket v1.8.7 26 | ) 27 | -------------------------------------------------------------------------------- /go/README.md: -------------------------------------------------------------------------------- 1 | # Go gRPC Web Wrapper 2 | 3 | The standard [gRPC-Go](http://www.grpc.io/docs/tutorials/basic/go.html) Server implements a 4 | [`ServeHTTP`](https://godoc.org/google.golang.org/grpc#Server.ServeHTTP) method and can be used as a standard 5 | `http.Handler` of the the Go built-in HTTP2 server. 6 | 7 | The `grpcweb` package implements a wrapper around `grpc.Server.ServeHTTP` that exposes the server's gRPC handlers over 8 | gRPC-Web spec, thus making them callable from browsers. 9 | 10 | The `grpcwebproxy` is a binary that can act as a reverse proxy for other gRPC servers (in whatever language), exposing 11 | their gRPC functionality to browsers (over HTTP2+TLS+gRPC-Web) without needing to modify their code. -------------------------------------------------------------------------------- /go/generate-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Generating markdown using godocdown in..." 4 | oldpwd="$(pwd)" 5 | for i in $(find . -iname 'doc.go'); do 6 | dir="${i%/*}" 7 | echo "- $dir" 8 | cd "${dir}" 9 | "${GOBIN}/godocdown" -heading=Title -o DOC.md 10 | ln -s DOC.md README.md 2> /dev/null # can fail 11 | cd "${oldpwd}" 12 | done; 13 | -------------------------------------------------------------------------------- /go/grpcweb/README.md: -------------------------------------------------------------------------------- 1 | DOC.md -------------------------------------------------------------------------------- /go/grpcweb/doc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Improbable. All Rights Reserved. 2 | // See LICENSE for licensing terms. 3 | 4 | /* 5 | `grpcweb` implements the gRPC-Web spec as a wrapper around a gRPC-Go Server. 6 | 7 | It allows web clients (see companion JS library) to talk to gRPC-Go servers over the gRPC-Web spec. It supports 8 | HTTP/1.1 and HTTP2 encoding of a gRPC stream and supports unary and server-side streaming RPCs. Bi-di and client 9 | streams are unsupported due to limitations in browser protocol support. 10 | 11 | See https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md for the protocol specification. 12 | 13 | Here's an example of how to use it inside an existing gRPC Go server on a separate http.Server that serves over TLS: 14 | 15 | grpcServer := grpc.Server() 16 | wrappedGrpc := grpcweb.WrapServer(grpcServer) 17 | tlsHttpServer.Handler = http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 18 | if wrappedGrpc.IsGrpcWebRequest(req) { 19 | wrappedGrpc.ServeHTTP(resp, req) 20 | return 21 | } 22 | // Fall back to other servers. 23 | http.DefaultServeMux.ServeHTTP(resp, req) 24 | }) 25 | 26 | If you'd like to have a standalone binary, please take a look at `grpcwebproxy`. 27 | 28 | */ 29 | package grpcweb 30 | -------------------------------------------------------------------------------- /go/grpcweb/grpc_web_response.go: -------------------------------------------------------------------------------- 1 | //Copyright 2017 Improbable. All Rights Reserved. 2 | // See LICENSE for licensing terms. 3 | 4 | package grpcweb 5 | 6 | import ( 7 | "bytes" 8 | "encoding/base64" 9 | "encoding/binary" 10 | "io" 11 | "net/http" 12 | "strings" 13 | 14 | "golang.org/x/net/http2" 15 | "google.golang.org/grpc/grpclog" 16 | ) 17 | 18 | // grpcWebResponse implements http.ResponseWriter. 19 | type grpcWebResponse struct { 20 | wroteHeaders bool 21 | wroteBody bool 22 | headers http.Header 23 | // Flush must be called on this writer before returning to ensure encoded buffer is flushed 24 | wrapped http.ResponseWriter 25 | 26 | // The standard "application/grpc" content-type will be replaced with this. 27 | contentType string 28 | } 29 | 30 | func newGrpcWebResponse(resp http.ResponseWriter, isTextFormat bool) *grpcWebResponse { 31 | g := &grpcWebResponse{ 32 | headers: make(http.Header), 33 | wrapped: resp, 34 | contentType: grpcWebContentType, 35 | } 36 | if isTextFormat { 37 | g.wrapped = newBase64ResponseWriter(g.wrapped) 38 | g.contentType = grpcWebTextContentType 39 | } 40 | return g 41 | } 42 | 43 | func (w *grpcWebResponse) Header() http.Header { 44 | return w.headers 45 | } 46 | 47 | func (w *grpcWebResponse) Write(b []byte) (int, error) { 48 | if !w.wroteHeaders { 49 | w.prepareHeaders() 50 | } 51 | w.wroteBody, w.wroteHeaders = true, true 52 | return w.wrapped.Write(b) 53 | } 54 | 55 | func (w *grpcWebResponse) WriteHeader(code int) { 56 | w.prepareHeaders() 57 | w.wrapped.WriteHeader(code) 58 | w.wroteHeaders = true 59 | } 60 | 61 | func (w *grpcWebResponse) Flush() { 62 | if w.wroteHeaders || w.wroteBody { 63 | // Work around the fact that WriteHeader and a call to Flush would have caused a 200 response. 64 | // This is the case when there is no payload. 65 | flushWriter(w.wrapped) 66 | } 67 | } 68 | 69 | // prepareHeaders runs all required header copying and transformations to 70 | // prepare the header of the wrapped response writer. 71 | func (w *grpcWebResponse) prepareHeaders() { 72 | wh := w.wrapped.Header() 73 | copyHeader( 74 | wh, w.headers, 75 | skipKeys("trailer"), 76 | replaceInKeys(http2.TrailerPrefix, ""), 77 | replaceInVals("content-type", grpcContentType, w.contentType), 78 | keyCase(http.CanonicalHeaderKey), 79 | ) 80 | responseHeaderKeys := headerKeys(wh) 81 | responseHeaderKeys = append(responseHeaderKeys, "grpc-status", "grpc-message") 82 | wh.Set( 83 | http.CanonicalHeaderKey("access-control-expose-headers"), 84 | strings.Join(responseHeaderKeys, ", "), 85 | ) 86 | } 87 | 88 | func (w *grpcWebResponse) finishRequest(req *http.Request) { 89 | if w.wroteHeaders || w.wroteBody { 90 | w.copyTrailersToPayload() 91 | } else { 92 | w.WriteHeader(http.StatusOK) 93 | flushWriter(w.wrapped) 94 | } 95 | } 96 | 97 | func (w *grpcWebResponse) copyTrailersToPayload() { 98 | trailers := extractTrailingHeaders(w.headers, w.wrapped.Header()) 99 | trailerBuffer := new(bytes.Buffer) 100 | trailers.Write(trailerBuffer) 101 | trailerGrpcDataHeader := []byte{1 << 7, 0, 0, 0, 0} // MSB=1 indicates this is a trailer data frame. 102 | binary.BigEndian.PutUint32(trailerGrpcDataHeader[1:5], uint32(trailerBuffer.Len())) 103 | w.wrapped.Write(trailerGrpcDataHeader) 104 | w.wrapped.Write(trailerBuffer.Bytes()) 105 | flushWriter(w.wrapped) 106 | } 107 | 108 | func extractTrailingHeaders(src http.Header, flushed http.Header) http.Header { 109 | th := make(http.Header) 110 | copyHeader( 111 | th, src, 112 | skipKeys(append([]string{"trailer"}, headerKeys(flushed)...)...), 113 | replaceInKeys(http2.TrailerPrefix, ""), 114 | // gRPC-Web spec says that must use lower-case header/trailer names. See 115 | // "HTTP wire protocols" section in 116 | // https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md#protocol-differences-vs-grpc-over-http2 117 | keyCase(strings.ToLower), 118 | ) 119 | return th 120 | } 121 | 122 | // An http.ResponseWriter wrapper that writes base64-encoded payloads. You must call Flush() 123 | // on this writer to ensure the base64-encoder flushes its last state. 124 | type base64ResponseWriter struct { 125 | wrapped http.ResponseWriter 126 | encoder io.WriteCloser 127 | } 128 | 129 | func newBase64ResponseWriter(wrapped http.ResponseWriter) http.ResponseWriter { 130 | w := &base64ResponseWriter{wrapped: wrapped} 131 | w.newEncoder() 132 | return w 133 | } 134 | 135 | func (w *base64ResponseWriter) newEncoder() { 136 | w.encoder = base64.NewEncoder(base64.StdEncoding, w.wrapped) 137 | } 138 | 139 | func (w *base64ResponseWriter) Header() http.Header { 140 | return w.wrapped.Header() 141 | } 142 | 143 | func (w *base64ResponseWriter) Write(b []byte) (int, error) { 144 | return w.encoder.Write(b) 145 | } 146 | 147 | func (w *base64ResponseWriter) WriteHeader(code int) { 148 | w.wrapped.WriteHeader(code) 149 | } 150 | 151 | func (w *base64ResponseWriter) Flush() { 152 | // Flush the base64 encoder by closing it. Grpc-web permits multiple padded base64 parts: 153 | // https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md 154 | err := w.encoder.Close() 155 | if err != nil { 156 | // Must ignore this error since Flush() is not defined as returning an error 157 | grpclog.Errorf("ignoring error Flushing base64 encoder: %v", err) 158 | } 159 | w.newEncoder() 160 | flushWriter(w.wrapped) 161 | } 162 | 163 | func flushWriter(w http.ResponseWriter) { 164 | f, ok := w.(http.Flusher) 165 | if !ok { 166 | return 167 | } 168 | 169 | f.Flush() 170 | } 171 | -------------------------------------------------------------------------------- /go/grpcweb/header.go: -------------------------------------------------------------------------------- 1 | //Copyright 2018 Improbable. All Rights Reserved. 2 | // See LICENSE for licensing terms. 3 | 4 | package grpcweb 5 | 6 | import ( 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | // replacer is the function that replaces the key and the slice of strings 12 | // per the header item. This function returns false as the last return value 13 | // if neither the key nor the slice were replaced. 14 | type replacer func(key string, vv []string) (string, []string, bool) 15 | 16 | // copyOptions acts as a storage for copyHeader options. 17 | type copyOptions struct { 18 | skipKeys map[string]bool 19 | replacers []replacer 20 | } 21 | 22 | // copyOption is the option type to pass to copyHeader function. 23 | type copyOption func(*copyOptions) 24 | 25 | // skipKeys returns an option to skip specified keys when copying headers 26 | // with copyHeader function. Key matching in the source header is 27 | // case-insensitive. 28 | func skipKeys(keys ...string) copyOption { 29 | return func(opts *copyOptions) { 30 | if opts.skipKeys == nil { 31 | opts.skipKeys = make(map[string]bool) 32 | } 33 | for _, k := range keys { 34 | // normalize the key 35 | opts.skipKeys[strings.ToLower(k)] = true 36 | } 37 | } 38 | } 39 | 40 | // replaceInVals returns an option to replace old substring with new substring 41 | // in header values keyed with key. Key matching in the header is 42 | // case-insensitive. 43 | func replaceInVals(key, old, new string) copyOption { 44 | return func(opts *copyOptions) { 45 | opts.replacers = append( 46 | opts.replacers, 47 | func(k string, vv []string) (string, []string, bool) { 48 | if strings.ToLower(key) == strings.ToLower(k) { 49 | vv2 := make([]string, 0, len(vv)) 50 | for _, v := range vv { 51 | vv2 = append( 52 | vv2, 53 | strings.Replace(v, old, new, 1), 54 | ) 55 | } 56 | return k, vv2, true 57 | } 58 | return "", nil, false 59 | }, 60 | ) 61 | } 62 | } 63 | 64 | // replaceInKeys returns an option to replace an old substring with a new 65 | // substring in header keys. 66 | func replaceInKeys(old, new string) copyOption { 67 | return func(opts *copyOptions) { 68 | opts.replacers = append( 69 | opts.replacers, 70 | func(k string, vv []string) (string, []string, bool) { 71 | if strings.Contains(k, old) { 72 | return strings.Replace(k, old, new, 1), vv, true 73 | } 74 | return "", nil, false 75 | }, 76 | ) 77 | } 78 | } 79 | 80 | // keyCase returns an option to unconditionally modify the case of the 81 | // destination header keys with function fn. Typically fn can be 82 | // strings.ToLower, strings.ToUpper, http.CanonicalHeaderKey 83 | func keyCase(fn func(string) string) copyOption { 84 | return func(opts *copyOptions) { 85 | opts.replacers = append( 86 | opts.replacers, 87 | func(k string, vv []string) (string, []string, bool) { 88 | return fn(k), vv, true 89 | }, 90 | ) 91 | } 92 | } 93 | 94 | // keyTrim returns an option to unconditionally trim the keys of the 95 | // destination header with function fn. Typically fn can be 96 | // strings.Trim, strings.TrimLeft/TrimRight, strings.TrimPrefix/TrimSuffix 97 | func keyTrim(fn func(string, string) string, cut string) copyOption { 98 | return func(opts *copyOptions) { 99 | opts.replacers = append( 100 | opts.replacers, 101 | func(k string, vv []string) (string, []string, bool) { 102 | return fn(k, cut), vv, true 103 | }, 104 | ) 105 | } 106 | } 107 | 108 | // copyHeader copies src to dst header. This function does not uses http.Header 109 | // methods internally, so header keys are copied as is. If any key normalization 110 | // is required, use keyCase option. 111 | func copyHeader( 112 | dst, src http.Header, 113 | opts ...copyOption, 114 | ) { 115 | options := new(copyOptions) 116 | for _, opt := range opts { 117 | opt(options) 118 | } 119 | 120 | for k, vv := range src { 121 | if options.skipKeys[strings.ToLower(k)] { 122 | continue 123 | } 124 | for _, r := range options.replacers { 125 | if k2, vv2, ok := r(k, vv); ok { 126 | k, vv = k2, vv2 127 | } 128 | } 129 | dst[k] = vv 130 | } 131 | } 132 | 133 | // headerKeys returns a slice of strings representing the keys in the header h. 134 | func headerKeys(h http.Header) []string { 135 | keys := make([]string, 0, len(h)) 136 | for k := range h { 137 | keys = append(keys, k) 138 | } 139 | return keys 140 | } 141 | -------------------------------------------------------------------------------- /go/grpcweb/health.go: -------------------------------------------------------------------------------- 1 | package grpcweb 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | backoff "github.com/cenkalti/backoff/v4" 8 | "google.golang.org/grpc" 9 | "google.golang.org/grpc/codes" 10 | healthpb "google.golang.org/grpc/health/grpc_health_v1" 11 | "google.golang.org/grpc/status" 12 | ) 13 | 14 | const healthCheckMethod = "/grpc.health.v1.Health/Watch" 15 | 16 | // Client health check function is also part of the grpc/internal package 17 | // The following code is a simplified version of client.go 18 | // For more details see: https://pkg.go.dev/google.golang.org/grpc/health 19 | func ClientHealthCheck(ctx context.Context, backendConn *grpc.ClientConn, service string, setServingStatus func(serving bool)) error { 20 | shouldBackoff := false // No need for backoff on the first connection attempt 21 | backoffSrc := backoff.NewExponentialBackOff() 22 | healthClient := healthpb.NewHealthClient(backendConn) 23 | 24 | for { 25 | // Backs off if the connection has failed in some way without receiving a message in the previous retry. 26 | if shouldBackoff { 27 | select { 28 | case <-time.After(backoffSrc.NextBackOff()): 29 | case <-ctx.Done(): 30 | return nil 31 | } 32 | } 33 | shouldBackoff = true // we should backoff next time, since we attempt connecting below 34 | 35 | req := healthpb.HealthCheckRequest{Service: service} 36 | s, err := healthClient.Watch(ctx, &req) 37 | if err != nil { 38 | continue 39 | } 40 | 41 | resp := new(healthpb.HealthCheckResponse) 42 | for { 43 | err = s.RecvMsg(resp) 44 | if err != nil { 45 | setServingStatus(false) 46 | // The health check functionality should be disabled if health check service is not implemented on the backend 47 | if status.Code(err) == codes.Unimplemented { 48 | return err 49 | } 50 | // breaking out of the loop, since we got an error from Recv, triggering reconnect 51 | break 52 | } 53 | 54 | // As a message has been received, removes the need for backoff for the next retry. 55 | shouldBackoff = false 56 | backoffSrc.Reset() 57 | 58 | if resp.Status == healthpb.HealthCheckResponse_SERVING { 59 | setServingStatus(true) 60 | } else { 61 | setServingStatus(false) 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /go/grpcweb/health_test.go: -------------------------------------------------------------------------------- 1 | package grpcweb_test 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "testing" 7 | "time" 8 | 9 | "github.com/improbable-eng/grpc-web/go/grpcweb" 10 | testproto "github.com/improbable-eng/grpc-web/integration_test/go/_proto/improbable/grpcweb/test" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "google.golang.org/grpc" 14 | "google.golang.org/grpc/health" 15 | healthpb "google.golang.org/grpc/health/grpc_health_v1" 16 | ) 17 | 18 | func TestClientWithNoHealthServiceOnServer(t *testing.T) { 19 | // Set up and run a server with no health check handler registered 20 | grpcServer := grpc.NewServer() 21 | testproto.RegisterTestServiceServer(grpcServer, &testServiceImpl{}) 22 | listener, err := net.Listen("tcp", "127.0.0.1:0") 23 | require.NoError(t, err) 24 | 25 | go func() { 26 | _ = grpcServer.Serve(listener) 27 | }() 28 | t.Cleanup(grpcServer.Stop) 29 | 30 | grpcClientConn, err := grpc.Dial(listener.Addr().String(), 31 | grpc.WithBlock(), 32 | grpc.WithTimeout(100*time.Millisecond), 33 | grpc.WithInsecure(), 34 | ) 35 | require.NoError(t, err) 36 | 37 | ctx := context.Background() 38 | 39 | servingStatus := true 40 | err = grpcweb.ClientHealthCheck(ctx, grpcClientConn, "", func(serving bool) { 41 | servingStatus = serving 42 | }) 43 | assert.Error(t, err) 44 | assert.False(t, servingStatus) 45 | } 46 | 47 | type clientHealthTestData struct { 48 | listener net.Listener 49 | serving bool 50 | healthServer *health.Server 51 | } 52 | 53 | func setupTestData(t *testing.T) clientHealthTestData { 54 | s := clientHealthTestData{} 55 | 56 | grpcServer := grpc.NewServer() 57 | s.healthServer = health.NewServer() 58 | healthpb.RegisterHealthServer(grpcServer, s.healthServer) 59 | 60 | var err error 61 | s.listener, err = net.Listen("tcp", "127.0.0.1:0") 62 | require.NoError(t, err) 63 | 64 | go func() { 65 | grpcServer.Serve(s.listener) 66 | }() 67 | t.Cleanup(grpcServer.Stop) 68 | 69 | return s 70 | } 71 | 72 | func (s *clientHealthTestData) dialBackend(t *testing.T) *grpc.ClientConn { 73 | grpcClientConn, err := grpc.Dial(s.listener.Addr().String(), 74 | grpc.WithBlock(), 75 | grpc.WithTimeout(100*time.Millisecond), 76 | grpc.WithInsecure(), 77 | ) 78 | require.NoError(t, err) 79 | return grpcClientConn 80 | } 81 | 82 | func (s *clientHealthTestData) checkServingStatus(t *testing.T, expStatus bool) { 83 | for start := time.Now(); time.Since(start) < 100*time.Millisecond; { 84 | if s.serving == expStatus { 85 | break 86 | } 87 | } 88 | assert.Equal(t, expStatus, s.serving) 89 | } 90 | 91 | func (s *clientHealthTestData) startClientHealthCheck(ctx context.Context, conn *grpc.ClientConn) { 92 | go func() { 93 | _ = grpcweb.ClientHealthCheck(ctx, conn, "", func(status bool) { 94 | s.serving = status 95 | }) 96 | }() 97 | } 98 | 99 | func TestClientHealthCheck_FailsIfNotServing(t *testing.T) { 100 | s := setupTestData(t) 101 | 102 | s.healthServer.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING) 103 | 104 | backendConn := s.dialBackend(t) 105 | ctx, cancel := context.WithCancel(context.Background()) 106 | defer cancel() 107 | 108 | s.startClientHealthCheck(ctx, backendConn) 109 | s.checkServingStatus(t, false) 110 | } 111 | 112 | func TestClientHealthCheck_SucceedsIfServing(t *testing.T) { 113 | s := setupTestData(t) 114 | 115 | s.healthServer.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) 116 | 117 | backendConn := s.dialBackend(t) 118 | ctx, cancel := context.WithCancel(context.Background()) 119 | defer cancel() 120 | 121 | s.startClientHealthCheck(ctx, backendConn) 122 | s.checkServingStatus(t, true) 123 | } 124 | 125 | func TestClientHealthCheck_ReactsToStatusChange(t *testing.T) { 126 | s := setupTestData(t) 127 | 128 | s.healthServer.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING) 129 | 130 | backendConn := s.dialBackend(t) 131 | ctx, cancel := context.WithCancel(context.Background()) 132 | defer cancel() 133 | 134 | s.startClientHealthCheck(ctx, backendConn) 135 | s.checkServingStatus(t, false) 136 | 137 | s.healthServer.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) 138 | s.checkServingStatus(t, true) 139 | 140 | s.healthServer.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING) 141 | s.checkServingStatus(t, false) 142 | } 143 | -------------------------------------------------------------------------------- /go/grpcweb/helpers.go: -------------------------------------------------------------------------------- 1 | //Copyright 2017 Improbable. All Rights Reserved. 2 | // See LICENSE for licensing terms. 3 | 4 | package grpcweb 5 | 6 | import ( 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "regexp" 11 | "strings" 12 | 13 | "google.golang.org/grpc" 14 | ) 15 | 16 | var pathMatcher = regexp.MustCompile(`/[^/]*/[^/]*$`) 17 | 18 | // ListGRPCResources is a helper function that lists all URLs that are registered on gRPC server. 19 | // 20 | // This makes it easy to register all the relevant routes in your HTTP router of choice. 21 | func ListGRPCResources(server *grpc.Server) []string { 22 | ret := []string{} 23 | for serviceName, serviceInfo := range server.GetServiceInfo() { 24 | for _, methodInfo := range serviceInfo.Methods { 25 | fullResource := fmt.Sprintf("/%s/%s", serviceName, methodInfo.Name) 26 | ret = append(ret, fullResource) 27 | } 28 | } 29 | return ret 30 | } 31 | 32 | // WebsocketRequestOrigin returns the host from which a websocket request made by a web browser 33 | // originated. 34 | func WebsocketRequestOrigin(req *http.Request) (string, error) { 35 | origin := req.Header.Get("Origin") 36 | parsed, err := url.ParseRequestURI(origin) 37 | if err != nil { 38 | return "", fmt.Errorf("failed to parse url for grpc-websocket origin check: %q. error: %v", origin, err) 39 | } 40 | return parsed.Host, nil 41 | } 42 | 43 | func getGRPCEndpoint(req *http.Request) string { 44 | endpoint := pathMatcher.FindString(strings.TrimRight(req.URL.Path, "/")) 45 | if len(endpoint) == 0 { 46 | return req.URL.Path 47 | } 48 | 49 | return endpoint 50 | } 51 | -------------------------------------------------------------------------------- /go/grpcweb/helpers_internal_test.go: -------------------------------------------------------------------------------- 1 | package grpcweb 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetGRPCEndpoint(t *testing.T) { 11 | cases := []struct { 12 | input string 13 | output string 14 | }{ 15 | {input: "/", output: "/"}, 16 | {input: "/resource", output: "/resource"}, 17 | {input: "/improbable.grpcweb.test.TestService/PingEmpty", output: "/improbable.grpcweb.test.TestService/PingEmpty"}, 18 | {input: "/improbable.grpcweb.test.TestService/PingEmpty/", output: "/improbable.grpcweb.test.TestService/PingEmpty"}, 19 | {input: "/a/b/c/improbable.grpcweb.test.TestService/PingEmpty", output: "/improbable.grpcweb.test.TestService/PingEmpty"}, 20 | {input: "/a/b/c/improbable.grpcweb.test.TestService/PingEmpty/", output: "/improbable.grpcweb.test.TestService/PingEmpty"}, 21 | } 22 | 23 | for _, c := range cases { 24 | req := httptest.NewRequest("GET", c.input, nil) 25 | result := getGRPCEndpoint(req) 26 | 27 | assert.Equal(t, c.output, result) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /go/grpcweb/helpers_test.go: -------------------------------------------------------------------------------- 1 | //Copyright 2017 Improbable. All Rights Reserved. 2 | // See LICENSE for licensing terms. 3 | 4 | package grpcweb_test 5 | 6 | import ( 7 | "sort" 8 | "testing" 9 | 10 | testproto "github.com/improbable-eng/grpc-web/integration_test/go/_proto/improbable/grpcweb/test" 11 | 12 | "github.com/improbable-eng/grpc-web/go/grpcweb" 13 | "github.com/stretchr/testify/assert" 14 | "google.golang.org/grpc" 15 | ) 16 | 17 | func TestListGRPCResources(t *testing.T) { 18 | server := grpc.NewServer() 19 | testproto.RegisterTestServiceServer(server, &testServiceImpl{}) 20 | expected := []string{ 21 | "/improbable.grpcweb.test.TestService/PingEmpty", 22 | "/improbable.grpcweb.test.TestService/Ping", 23 | "/improbable.grpcweb.test.TestService/PingError", 24 | "/improbable.grpcweb.test.TestService/PingList", 25 | "/improbable.grpcweb.test.TestService/Echo", 26 | "/improbable.grpcweb.test.TestService/PingPongBidi", 27 | "/improbable.grpcweb.test.TestService/PingStream", 28 | } 29 | actual := grpcweb.ListGRPCResources(server) 30 | sort.Strings(expected) 31 | sort.Strings(actual) 32 | assert.EqualValues(t, 33 | expected, 34 | actual, 35 | "list grpc resources must provide an exhaustive list of all registered handlers") 36 | } 37 | -------------------------------------------------------------------------------- /go/grpcweb/trailer.go: -------------------------------------------------------------------------------- 1 | //Copyright 2018 Improbable. All Rights Reserved. 2 | // See LICENSE for licensing terms. 3 | 4 | package grpcweb 5 | 6 | import ( 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | // gRPC-Web spec says that must use lower-case header/trailer names. 12 | // See "HTTP wire protocols" section in 13 | // https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md#protocol-differences-vs-grpc-over-http2 14 | type trailer struct { 15 | http.Header 16 | } 17 | 18 | func (t trailer) Add(key, value string) { 19 | key = strings.ToLower(key) 20 | t.Header[key] = append(t.Header[key], value) 21 | } 22 | 23 | func (t trailer) Get(key string) string { 24 | if t.Header == nil { 25 | return "" 26 | } 27 | v := t.Header[key] 28 | if len(v) == 0 { 29 | return "" 30 | } 31 | return v[0] 32 | } 33 | -------------------------------------------------------------------------------- /go/grpcweb/trailer_test.go: -------------------------------------------------------------------------------- 1 | package grpcweb 2 | 3 | import "net/http" 4 | 5 | type Trailer struct { 6 | trailer 7 | } 8 | 9 | func HTTPTrailerToGrpcWebTrailer(httpTrailer http.Header) Trailer { 10 | return Trailer{trailer{httpTrailer}} 11 | } 12 | -------------------------------------------------------------------------------- /go/grpcwebproxy/.gitignore: -------------------------------------------------------------------------------- 1 | grpcwebproxy -------------------------------------------------------------------------------- /go/grpcwebproxy/README.md: -------------------------------------------------------------------------------- 1 | # gRPC Web Proxy 2 | 3 | This is a small reverse proxy that can front existing gRPC servers and expose their functionality using gRPC-Web 4 | protocol, allowing for the gRPC services to be consumed from browsers. 5 | 6 | Features: 7 | * structured logging of proxied requests to stdout 8 | * debug HTTP endpoint (default on port `8080`) 9 | * Prometheus monitoring of proxied requests (`/metrics` on debug endpoint) 10 | * Request (`/debug/requests`) and connection tracing endpoints (`/debug/events`) 11 | * TLS 1.2 serving (default on port `8443`): 12 | * with option to enable client side certificate validation 13 | * both secure (plaintext) and TLS gRPC backend connectivity: 14 | * with customizable CA certificates for connections 15 | 16 | The intended use is as a companion process for gRPC server containers. 17 | 18 | ## Installing 19 | 20 | ### Pre-built binaries 21 | 22 | There are pre-built binaries available for Windows, Mac and Linux (ARM and x86_64): 23 | https://github.com/improbable-eng/grpc-web/releases 24 | 25 | ### Building from source 26 | 27 | To build, you need to have Go >= 1.8, and call `go get` with `dep ensure`: 28 | 29 | ```sh 30 | GOPATH=~/go ; export GOPATH 31 | git clone https://github.com/improbable-eng/grpc-web.git $GOPATH/src/github.com/improbable-eng/grpc-web 32 | cd $GOPATH/src/github.com/improbable-eng/grpc-web 33 | dep ensure # after installing dep 34 | go install ./go/grpcwebproxy # installs into $GOPATH/bin/grpcwebproxy 35 | ``` 36 | 37 | ## Running 38 | 39 | Here's a simple example that fronts a local, TLS gRPC server: 40 | 41 | ```sh 42 | grpcwebproxy 43 | --server_tls_cert_file=../../misc/localhost.crt \ 44 | --server_tls_key_file=../../misc/localhost.key \ 45 | --backend_addr=localhost:9090 \ 46 | --backend_tls_noverify 47 | ``` 48 | 49 | ### Running specific servers 50 | 51 | By default, grpcwebproxy will run both TLS and HTTP debug servers. To disable either one, set the `--run_http_server` or `--run_tls_server` flags to false. 52 | 53 | For example, to only run the HTTP server, run the following: 54 | 55 | ```sh 56 | grpcwebproxy 57 | --backend_addr=localhost:9090 \ 58 | --run_tls_server=false 59 | ``` 60 | 61 | ### Enabling Websocket Transport 62 | 63 | By default, grpcwebproxy will not use websockets as a transport layer. To enable websockets, set the `--use_websockets` flag to true. 64 | 65 | ``` 66 | $GOPATH/bin/grpcwebproxy \ 67 | --backend_addr=localhost:9090 \ 68 | --use_websockets 69 | ``` 70 | 71 | ### Changing Websocket Compression 72 | By default, websocket compression is used as `no context takover`. To override compression type, use the `--websocket_compression_mode` option. 73 | Available options are `no_context_takeover`, `context_takeover`, `disabled`. Websocket compression types are described in [RFC 7692](https://datatracker.ietf.org/doc/html/rfc7692). 74 | 75 | For example, for disabling websocket compression run the following: 76 | ``` 77 | $GOPATH/bin/grpcwebproxy \ 78 | --backend_addr=localhost:9090 \ 79 | --use_websockets \ 80 | --websocket_compression_mode=disabled 81 | ``` 82 | 83 | ### Changing the Maximum Receive Message Size 84 | 85 | By default, grpcwebproxy will limit the message size that the backend sends to the client. This is currently 4MB. 86 | To override this, set the `--backend_max_call_recv_msg_size` flag to an integer with the desired byte size. 87 | 88 | For example, to increase the size to 5MB, set the value to 5242880 (5 * 1024 * 1024). 89 | 90 | ```bash 91 | grpcwebproxy \ 92 | --backend_max_call_recv_msg_size=5242880 93 | ``` 94 | 95 | Note that if you set a lower value than 4MB, the lower value will be used. Also, it is preferrable to send data in a stream than to set a very large value. 96 | 97 | ### Configuring CORS for Http and WebSocket connections 98 | 99 | By default, grpcwebproxy will reject any request originating from a client running on any domain other than that of where the server is hosted, this can be configured via one of the `--allow_all_origins` or `--allowed_origins` flags. 100 | 101 | For example, to allow requests from any origin: 102 | 103 | ```bash 104 | grpcwebproxy \ 105 | --allow_all_origins 106 | ``` 107 | 108 | Or to only allow requests from a specific list of origins: 109 | 110 | ```bash 111 | grpcwebproxy \ 112 | --allowed_origins=https://example.org,https://awesome.com 113 | ``` 114 | -------------------------------------------------------------------------------- /go/grpcwebproxy/backend.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "io/ioutil" 7 | 8 | "github.com/mwitkow/grpc-proxy/proxy" 9 | "github.com/sirupsen/logrus" 10 | "github.com/spf13/pflag" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/credentials" 13 | ) 14 | 15 | var ( 16 | flagBackendHostPort = pflag.String( 17 | "backend_addr", 18 | "", 19 | "A host:port (IP or hostname) of the gRPC server to forward it to.") 20 | 21 | flagBackendIsUsingTls = pflag.Bool( 22 | "backend_tls", 23 | false, 24 | "Whether the gRPC server of the backend is serving in plaintext (false) or over TLS (true).", 25 | ) 26 | 27 | flagBackendTlsNoVerify = pflag.Bool( 28 | "backend_tls_noverify", 29 | false, 30 | "Whether to ignore TLS verification checks (cert validity, hostname). *DO NOT USE IN PRODUCTION*.", 31 | ) 32 | 33 | flagBackendTlsClientCert = pflag.String( 34 | "backend_client_tls_cert_file", 35 | "", 36 | "Path to the PEM certificate used when the backend requires client certificates for TLS.") 37 | 38 | flagBackendTlsClientKey = pflag.String( 39 | "backend_client_tls_key_file", 40 | "", 41 | "Path to the PEM key used when the backend requires client certificates for TLS.") 42 | 43 | flagMaxCallRecvMsgSize = pflag.Int( 44 | "backend_max_call_recv_msg_size", 45 | 1024*1024*4, // The current maximum receive msg size per https://github.com/grpc/grpc-go/blob/v1.8.2/server.go#L54 46 | "Maximum receive message size limit. If not specified, the default of 4MB will be used.", 47 | ) 48 | 49 | flagBackendTlsCa = pflag.StringSlice( 50 | "backend_tls_ca_files", 51 | []string{}, 52 | "Paths (comma separated) to PEM certificate chains used for verification of backend certificates. If empty, host CA chain will be used.", 53 | ) 54 | 55 | flagBackendDefaultAuthority = pflag.String( 56 | "backend_default_authority", 57 | "", 58 | "Default value to use for the HTTP/2 :authority header commonly used for routing gRPC calls through a backend gateway.", 59 | ) 60 | 61 | flagBackendBackoffMaxDelay = pflag.Duration( 62 | "backend_backoff_max_delay", 63 | grpc.DefaultBackoffConfig.MaxDelay, 64 | "Maximum delay when backing off after failed connection attempts to the backend.", 65 | ) 66 | ) 67 | 68 | func dialBackendOrFail() *grpc.ClientConn { 69 | if *flagBackendHostPort == "" { 70 | logrus.Fatalf("flag 'backend_addr' must be set") 71 | } 72 | opt := []grpc.DialOption{} 73 | opt = append(opt, grpc.WithCodec(proxy.Codec())) 74 | 75 | if *flagBackendDefaultAuthority != "" { 76 | opt = append(opt, grpc.WithAuthority(*flagBackendDefaultAuthority)) 77 | } 78 | 79 | if *flagBackendIsUsingTls { 80 | opt = append(opt, grpc.WithTransportCredentials(credentials.NewTLS(buildBackendTlsOrFail()))) 81 | } else { 82 | opt = append(opt, grpc.WithInsecure()) 83 | } 84 | 85 | opt = append(opt, 86 | grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(*flagMaxCallRecvMsgSize)), 87 | grpc.WithBackoffMaxDelay(*flagBackendBackoffMaxDelay), 88 | ) 89 | 90 | cc, err := grpc.Dial(*flagBackendHostPort, opt...) 91 | if err != nil { 92 | logrus.Fatalf("failed dialing backend: %v", err) 93 | } 94 | return cc 95 | } 96 | 97 | func buildBackendTlsOrFail() *tls.Config { 98 | tlsConfig := &tls.Config{} 99 | tlsConfig.MinVersion = tls.VersionTLS12 100 | if *flagBackendTlsNoVerify { 101 | tlsConfig.InsecureSkipVerify = true 102 | } else { 103 | if len(*flagBackendTlsCa) > 0 { 104 | tlsConfig.RootCAs = x509.NewCertPool() 105 | for _, path := range *flagBackendTlsCa { 106 | data, err := ioutil.ReadFile(path) 107 | if err != nil { 108 | logrus.Fatalf("failed reading backend CA file %v: %v", path, err) 109 | } 110 | if ok := tlsConfig.RootCAs.AppendCertsFromPEM(data); !ok { 111 | logrus.Fatalf("failed processing backend CA file %v", path) 112 | } 113 | } 114 | } 115 | } 116 | if *flagBackendTlsClientCert != "" || *flagBackendTlsClientKey != "" { 117 | if *flagBackendTlsClientCert == "" { 118 | logrus.Fatal("flag 'backend_client_tls_cert_file' must be set when 'backend_client_tls_key_file' is set") 119 | } 120 | if *flagBackendTlsClientKey == "" { 121 | logrus.Fatal("flag 'backend_client_tls_key_file' must be set when 'backend_client_tls_cert_file' is set") 122 | } 123 | cert, err := tls.LoadX509KeyPair(*flagBackendTlsClientCert, *flagBackendTlsClientKey) 124 | if err != nil { 125 | logrus.Fatalf("failed reading TLS client keys: %v", err) 126 | } 127 | tlsConfig.Certificates = append(tlsConfig.Certificates, cert) 128 | } 129 | return tlsConfig 130 | } 131 | -------------------------------------------------------------------------------- /go/grpcwebproxy/server_tls.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | 6 | "github.com/mwitkow/go-conntrack/connhelpers" 7 | logrus "github.com/sirupsen/logrus" 8 | "github.com/spf13/pflag" 9 | 10 | "crypto/x509" 11 | "io/ioutil" 12 | ) 13 | 14 | var ( 15 | flagTlsServerCert = pflag.String( 16 | "server_tls_cert_file", 17 | "", 18 | "Path to the PEM certificate for server use.") 19 | 20 | flagTlsServerKey = pflag.String( 21 | "server_tls_key_file", 22 | "../misc/localhost.key", 23 | "Path to the PEM key for the certificate for the server use.") 24 | 25 | flagTlsServerClientCertVerification = pflag.String( 26 | "server_tls_client_cert_verification", 27 | "none", 28 | "Controls whether a client certificate is on. Values: none, verify_if_given, require.") 29 | 30 | flagTlsServerClientCAFiles = pflag.StringSlice( 31 | "server_tls_client_ca_files", 32 | []string{}, 33 | "Paths (comma separated) to PEM certificate chains used for client-side verification. If empty, host CA chain will be used.", 34 | ) 35 | ) 36 | 37 | func buildServerTlsOrFail() *tls.Config { 38 | if *flagTlsServerCert == "" || *flagTlsServerKey == "" { 39 | logrus.Fatalf("flags server_tls_cert_file and server_tls_key_file must be set") 40 | } 41 | tlsConfig, err := connhelpers.TlsConfigForServerCerts(*flagTlsServerCert, *flagTlsServerKey) 42 | if err != nil { 43 | logrus.Fatalf("failed reading TLS server keys: %v", err) 44 | } 45 | tlsConfig.MinVersion = tls.VersionTLS12 46 | switch *flagTlsServerClientCertVerification { 47 | case "none": 48 | tlsConfig.ClientAuth = tls.NoClientCert 49 | case "verify_if_given": 50 | tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven 51 | case "require": 52 | tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert 53 | default: 54 | logrus.Fatalf("Uknown value '%v' for server_tls_client_cert_verification", *flagTlsServerClientCertVerification) 55 | } 56 | if tlsConfig.ClientAuth != tls.NoClientCert { 57 | if len(*flagTlsServerClientCAFiles) > 0 { 58 | tlsConfig.ClientCAs = x509.NewCertPool() 59 | for _, path := range *flagTlsServerClientCAFiles { 60 | data, err := ioutil.ReadFile(path) 61 | if err != nil { 62 | logrus.Fatalf("failed reading client CA file %v: %v", path, err) 63 | } 64 | if ok := tlsConfig.ClientCAs.AppendCertsFromPEM(data); !ok { 65 | logrus.Fatalf("failed processing client CA file %v", path) 66 | } 67 | } 68 | } else { 69 | var err error 70 | tlsConfig.ClientCAs, err = x509.SystemCertPool() 71 | if err != nil { 72 | logrus.Fatalf("no client CA files specified, fallback to system CA chain failed: %v", err) 73 | } 74 | } 75 | 76 | } 77 | tlsConfig, err = connhelpers.TlsConfigWithHttp2Enabled(tlsConfig) 78 | if err != nil { 79 | logrus.Fatalf("can't configure h2 handling: %v", err) 80 | } 81 | return tlsConfig 82 | } 83 | -------------------------------------------------------------------------------- /go/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script that checks the code for errors. 3 | 4 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" 5 | 6 | function print_real_go_files { 7 | grep --files-without-match 'DO NOT EDIT!' $(find . -iname '*.go') 8 | } 9 | 10 | function check_no_documentation_changes { 11 | echo "- Running generate-docs.sh" 12 | output=$(./generate-docs.sh) 13 | if [[ $? -ne 0 ]]; then 14 | echo $output 15 | echo "ERROR: Failed to generate documentation." 16 | exit 1 17 | fi 18 | 19 | git diff --name-only | grep -q DOC.md 20 | if [[ $? -ne 1 ]]; then 21 | echo "ERROR: Documentation changes detected, please commit them." 22 | exit 1 23 | fi 24 | } 25 | 26 | function check_gofmt { 27 | echo "- Running gofmt" 28 | out=$(gofmt -l -w $(print_real_go_files)) 29 | if [[ ! -z $out ]]; then 30 | echo "ERROR: gofmt changes detected, please commit them." 31 | exit 1 32 | fi 33 | } 34 | 35 | function goimports_all { 36 | echo "- Running goimports" 37 | ${GOBIN}/goimports -l -w $(print_real_go_files) 38 | if [[ $? -ne 0 ]]; then 39 | echo "ERROR: goimports changes detected, please commit them." 40 | exit 1 41 | fi 42 | } 43 | 44 | function govet_all { 45 | echo "- Running govet" 46 | go vet -all=true -tests=false ./... 47 | if [[ $? -ne 0 ]]; then 48 | echo "ERROR: govet errors detected, please commit/fix them." 49 | exit 1 50 | fi 51 | } 52 | 53 | check_no_documentation_changes 54 | check_gofmt 55 | goimports_all 56 | govet_all 57 | echo 58 | -------------------------------------------------------------------------------- /install-buf.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Installs buf; used for generating and linting protofiles. 3 | # This script is intended to be run by CI 4 | 5 | set -ex 6 | 7 | if [[ -z "$BUF_VER" ]]; then 8 | echo "BUF_VER environment variable not set" 9 | exit 1 10 | fi 11 | 12 | curl -sSL \ 13 | https://github.com/bufbuild/buf/releases/download/v${BUF_VER}/buf-$(uname -s)-$(uname -m) \ 14 | -o ./buf && \ 15 | chmod +x ./buf 16 | 17 | echo 'export PATH=$PATH:$PWD' >> $BASH_ENV 18 | -------------------------------------------------------------------------------- /install-protoc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Installs protoc; used for generating JS protobuf stubs. 3 | # This script is intended to be run by CI 4 | 5 | set -ex 6 | 7 | if [[ -z "$PROTOC_VER" ]]; then 8 | echo "PROTOC_VER environment variable not set" 9 | exit 1 10 | fi 11 | 12 | curl -sSL \ 13 | https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VER}/protoc-${PROTOC_VER}-linux-$(uname -m).zip \ 14 | -o protoc.zip 15 | rm -rf protoc 16 | mkdir -p protoc 17 | unzip protoc.zip -d protoc 18 | rm protoc.zip 19 | chmod +x ./protoc/bin/protoc 20 | 21 | echo 'export PATH=$PATH:$PWD/protoc/bin' >> $BASH_ENV 22 | -------------------------------------------------------------------------------- /integration_test/.gitignore: -------------------------------------------------------------------------------- 1 | build-node/ 2 | build/ 3 | test-results/ 4 | sauce-connect-proxy/ 5 | -------------------------------------------------------------------------------- /integration_test/README.md: -------------------------------------------------------------------------------- 1 | ### Running Tests 2 | 3 | Follow the [CONTRIBUTING](../CONTRIBUTING.md) guide. 4 | -------------------------------------------------------------------------------- /integration_test/browsers.ts: -------------------------------------------------------------------------------- 1 | function browser(browserName, browserVersion, os, options?: {}) { 2 | return { 3 | configName: `${os}_${browserName}_${browserVersion}`, 4 | base: 'CustomWebDriver', 5 | capabilities: { 6 | ...(options || {}), 7 | testSuite: undefined, 8 | browserName: browserName, 9 | browserVersion: browserVersion, 10 | os: os, 11 | } 12 | } 13 | } 14 | 15 | const browsers = { 16 | // Firefox 17 | firefox86_win: browser('firefox', '86', 'Windows 10',{custom: {acceptInsecureCerts: true}}), 18 | firefox39_win: browser('firefox', '39', 'Windows 10',{custom: {acceptInsecureCerts: true}}), // Basic fetch added in 39 19 | firefox38_win: browser('firefox', '38', 'Windows 10',{custom: {acceptInsecureCerts: true}}), 20 | 21 | // Chrome 22 | chrome_89: browser('chrome', '89', 'Windows 10', {certOverrideJSElement: 'proceed-link'}), 23 | chrome_52: browser('chrome', '52', 'Windows 10'), 24 | chrome_43: browser('chrome', '43', 'Linux'), // Readable stream fetch support added in 43 25 | chrome_42: browser('chrome', '42', 'Linux'), // Basic fetch added in 42 26 | chrome_41: browser('chrome', '41', 'Linux'), 27 | 28 | // Edge 29 | edge88_win: browser('MicrosoftEdge', '88', 'Windows 10', {certOverrideJSElement: 'proceed-link'}), 30 | edge16_win: browser('MicrosoftEdge', '16', 'Windows 10', {certOverrideJSElement: 'invalidcert_continue'}), 31 | edge14_win: browser('MicrosoftEdge', '14', 'Windows 10', {certOverrideJSElement: 'invalidcert_continue'}), 32 | edge13_win: browser('MicrosoftEdge', '13', 'Windows 10', {certOverrideJSElement: 'invalidcert_continue'}), 33 | 34 | // Safari 35 | safari14: browser('safari', '14', 'macOS 11.00', {useSslBumping: true}), 36 | safari13_1: browser('safari', '13.1', 'OS X 10.15', {useSslBumping: true}), 37 | safari12_1: browser('safari', '12.0', 'OS X 10.14',{useSslBumping: true}), 38 | safari11_1: browser('safari', '11.1', 'OS X 10.13',{useSslBumping: true}), 39 | safari10_1: browser('safari', '10.1', 'OS X 10.12',{useSslBumping: true}), 40 | safari9_1: browser('safari', '9.0', 'OS X 10.11',{useSslBumping: true}), 41 | safari8: browser('safari', '8.0', 'OS X 10.10',{useSslBumping: true}), 42 | 43 | // IE 44 | ie11_win: browser('internet explorer', '11', 'Windows 10', {certOverrideJSElement: 'overridelink', disableWebsocketTests: true}), 45 | }; 46 | 47 | export default () => { 48 | const browserEnv = process.env.BROWSER; 49 | 50 | const filteredBrowsers = {}; 51 | if (browserEnv) { 52 | const foundBrowser = browsers[browserEnv]; 53 | if (!foundBrowser) { 54 | throw new Error(`BROWSER env var set to "${browserEnv}", but there is no browser with that identifier`); 55 | } 56 | filteredBrowsers[foundBrowser.configName] = foundBrowser; 57 | return filteredBrowsers; 58 | } 59 | 60 | for(let i in browsers) { 61 | const browserConfig = browsers[i] 62 | filteredBrowsers[browserConfig.configName] = browserConfig; 63 | } 64 | return filteredBrowsers 65 | }; 66 | -------------------------------------------------------------------------------- /integration_test/custom-karma-driver.ts: -------------------------------------------------------------------------------- 1 | import { Builder } from 'selenium-webdriver'; 2 | import { corsHost, testHost } from "./hosts-config"; 3 | 4 | const username = process.env.SAUCELABS_USERNAME; 5 | const accessKey = process.env.SAUCELABS_ACCESS_KEY; 6 | const buildName = process.env.CIRCLE_WORKFLOW_ID ? `circleci_${process.env.CIRCLE_WORKFLOW_ID}` : `local_${new Date().getTime()}`; 7 | const sauceLabsTunnelWithSSLBumping = process.env.SAUCELABS_TUNNEL_ID_WITH_SSL_BUMP; 8 | const sauceLabsTunnelNoSSLBumping = process.env.SAUCELABS_TUNNEL_ID_NO_SSL_BUMP; 9 | 10 | const viaUrls = [ 11 | // HTTP 1.1 12 | "https://" + testHost + ":9090", 13 | "https://" + testHost + ":9095", 14 | "https://" + corsHost + ":9090", 15 | "https://" + corsHost + ":9095", 16 | // HTTP 2 17 | "https://" + testHost + ":9100", 18 | "https://" + testHost + ":9105", 19 | "https://" + corsHost + ":9100", 20 | "https://" + corsHost + ":9105" 21 | ]; 22 | 23 | function CustomWebdriverBrowser(id, baseBrowserDecorator, args, logger) { 24 | baseBrowserDecorator(this); 25 | const self = this; 26 | self.name = args.configName; 27 | self.log = logger.create(`launcher.selenium-webdriver: ${self.name}`); 28 | self.captured = false; 29 | self.ended = false; 30 | self.id = id; 31 | const caps = args.capabilities; 32 | self._start = (testUrl) => { 33 | const testUrlWithSuite = `${testUrl}#${caps.disableWebsocketTests ? 'disableWebsocketTests' : ''}`; 34 | const tunnelIdentifier = caps.useSslBumping ? sauceLabsTunnelWithSSLBumping : sauceLabsTunnelNoSSLBumping; 35 | self.log.debug('Local Tunnel Connected. Now testing...'); 36 | let browser = new Builder() 37 | .withCapabilities({ 38 | ...(caps.custom || {}), 39 | 'name': `${caps.browserName} - Integration Test`, 40 | 'browserName': caps.browserName, 41 | 'platform': caps.os, 42 | 'version': caps.browserVersion, 43 | 'build': buildName, 44 | 'username': username, 45 | 'accessKey': accessKey, 46 | 'tunnelIdentifier': tunnelIdentifier, 47 | 'recordScreenshots': false, 48 | 'acceptSslCerts': true, 49 | 'javascriptEnabled': true, 50 | 'commandTimeout': 600, 51 | 'idleTimeout': 600, 52 | 'maxDuration': 600, 53 | }) 54 | .usingServer("https://" + username + ":" + accessKey + "@ondemand.saucelabs.com:443/wd/hub") 55 | .build(); 56 | 57 | self.log.debug("Built webdriver"); 58 | 59 | self.browser = browser; 60 | if (caps.certOverrideJSElement) { 61 | const next = (i) => { 62 | const via = viaUrls[i]; 63 | if (!via) { 64 | self.log.debug("Navigating to ", testUrlWithSuite); 65 | browser.get(testUrlWithSuite).then(() => { 66 | self.log.debug("Did capture"); 67 | self.captured = true; 68 | 69 | self.log.debug("Attempting to bypass cert issue on final") 70 | browser.executeScript(`var el = document.getElementById('${caps.certOverrideJSElement}'); if (el) {el.click()}`); 71 | // This will wait on the page until the browser is killed 72 | }); 73 | } else { 74 | browser.get(via).then(() => { 75 | self.log.debug("Attempting to bypass cert issue") 76 | browser.executeScript(`var el = document.getElementById('${caps.certOverrideJSElement}'); if (el) {el.click()}`).then(() => { 77 | setTimeout(() => { 78 | next(i + 1); 79 | }, 5000); 80 | }); 81 | }).catch(err => { 82 | console.error("Failed to navigate via page", err); 83 | }); 84 | } 85 | }; 86 | next(0); 87 | } else { 88 | self.log.debug("Navigating to ", testUrlWithSuite); 89 | browser.get(testUrlWithSuite).then(() => { 90 | self.log.debug("Did capture"); 91 | self.captured = true; 92 | }); 93 | } 94 | }; 95 | 96 | this.on('kill', function (done) { 97 | self.log.debug("KarmaDriver.kill") 98 | self.ended = true; 99 | self.log.debug("KarmaDriver.quit()") 100 | self.browser.quit().finally(() => { 101 | self.log.debug("KarmaDriver.quit.finally") 102 | self._done(); 103 | done(); 104 | }); 105 | }); 106 | 107 | self.isCaptured = function () { 108 | return self.captured; 109 | }; 110 | } 111 | 112 | export default { 113 | 'launcher:CustomWebDriver': ['type', CustomWebdriverBrowser] 114 | }; 115 | -------------------------------------------------------------------------------- /integration_test/hosts-config.ts: -------------------------------------------------------------------------------- 1 | // These must match the hosts configured in .circleci/config.yml 2 | export const testHost = "testhost"; 3 | export const corsHost = "corshost"; 4 | -------------------------------------------------------------------------------- /integration_test/karma.conf.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import customLaunchersGenerator from './browsers'; 3 | import customKarmaDriver from './custom-karma-driver'; 4 | import {testHost} from './hosts-config'; 5 | 6 | const junitReportDirectory = process.env.JUNIT_REPORT_PATH || './test-results'; 7 | 8 | export default (config) => { 9 | const customLaunchers = customLaunchersGenerator(); 10 | const DEBUG = process.env.DEBUG !== undefined; 11 | const DISABLE_WEBSOCKET_TESTS = process.env.DISABLE_WEBSOCKET_TESTS !== undefined; 12 | const useSauceLabs = process.env.SAUCELABS_USERNAME !== undefined; 13 | const browsers = useSauceLabs ? Object.keys(customLaunchers) : []; 14 | 15 | config.set({ 16 | basePath: '', 17 | frameworks: ['jasmine'], 18 | jasmine: { 19 | random: false, 20 | }, 21 | files: [ 22 | 'ts/build/integration-tests.js' 23 | ], 24 | preprocessors: { 25 | '**/*.js': ['sourcemap', 'config-inject'] 26 | }, 27 | reporters: ['mocha', 'junit'], 28 | junitReporter: { 29 | outputDir: junitReportDirectory, 30 | }, 31 | protocol: 'https', 32 | hostname: testHost, 33 | port: 9876, 34 | httpsServerOptions: { 35 | key: fs.readFileSync('../misc/localhost.key', 'utf8'), 36 | cert: fs.readFileSync('../misc/localhost.crt', 'utf8') 37 | }, 38 | colors: true, 39 | logLevel: DEBUG ? 'DEBUG' : 'INFO', 40 | client: { 41 | captureConsole: true, 42 | runInParent: false, 43 | useIframe: true, 44 | }, 45 | plugins: [ 46 | customKarmaDriver, 47 | {'preprocessor:config-inject': [ 48 | 'factory', () => 49 | (content, file, done) => 50 | done(`window.DEBUG = ${DEBUG};window.DISABLE_WEBSOCKET_TESTS = ${DISABLE_WEBSOCKET_TESTS};\n${content}`) 51 | ]}, 52 | 'karma-sourcemap-loader', 53 | 'karma-mocha-reporter', 54 | 'karma-junit-reporter', 55 | 'karma-jasmine' 56 | ], 57 | transports: ['polling'], 58 | autoWatch: true, 59 | disconnectTolerance: 5, 60 | captureTimeout: 300000, 61 | browserDisconnectTimeout: 300000, 62 | browserNoActivityTimeout: 300000, 63 | singlerun: useSauceLabs, 64 | concurrency: 4, 65 | customLaunchers: customLaunchers, 66 | browsers: browsers 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /integration_test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grpc-web-integration-test", 3 | "version": "0.15.0", 4 | "private": true, 5 | "scripts": { 6 | "clean": "rm -rf ts/build && rm -rf ts/build-node", 7 | "build:testserver": "cd go && go build -o ./build/testserver ./testserver/testserver.go", 8 | "build:proto": "cd proto && buf generate", 9 | "build:ts": "cd ts && rm -rf build && webpack-cli", 10 | "build:dev": "cd ts && rm -rf build && webpack-cli --watch", 11 | "build:node": "cd ts && rm -rf build-node && (cd node-src && tsc) && (cp -R _proto build-node/integration_test/ts)", 12 | "build": "npm run build:proto && npm run build:ts && npm run build:node && npm run build:testserver", 13 | "start:ci": "./test-ci.sh", 14 | "start": "./test.sh", 15 | "test:browsers": "./run-with-tunnel.sh ./run-with-testserver.sh karma start ./karma.conf.ts --single-run", 16 | "test:node": "BROWSER=nodejs ./run-with-testserver.sh jasmine ts/build-node/integration_test/ts/node-src/node.spec.js", 17 | "test:dev": "npm run build && ./run-with-testserver.sh karma start ./karma.conf.ts" 18 | }, 19 | "license": "none", 20 | "dependencies": { 21 | "@improbable-eng/grpc-web": "^0.15.0", 22 | "@improbable-eng/grpc-web-node-http-transport": "^0.15.0", 23 | "browser-headers": "^0.4.1", 24 | "event-stream": "^4.0.1", 25 | "google-protobuf": "3.14.0", 26 | "typedarray": "0.0.6" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.12.10", 30 | "@types/chai": "^4.2.14", 31 | "@types/google-protobuf": "^3.7.4", 32 | "@types/jasmine": "^3.6.3", 33 | "@types/lodash": "^4.14.168", 34 | "@types/node": "^14.14.22", 35 | "assert": "^2.0.0", 36 | "babel-loader": "^8.2.2", 37 | "babel-preset-env": "^1.7.0", 38 | "chai": "^4.2.0", 39 | "colors": "^1.4.0", 40 | "jasmine": "3.6.4", 41 | "jasmine-core": "3.6.0", 42 | "karma": "6.2.0", 43 | "karma-jasmine": "4.0.1", 44 | "karma-junit-reporter": "2.0.1", 45 | "karma-mocha-reporter": "2.2.5", 46 | "karma-sourcemap-loader": "^0.3.8", 47 | "lodash": "^4.17.20", 48 | "sauce-connect-launcher": "^1.3.2", 49 | "selenium-webdriver": "^4.0.0-alpha.8", 50 | "ts-loader": "^8.0.14", 51 | "ts-node": "^9.1.1", 52 | "ts-protoc-gen": "0.14.0", 53 | "typescript": "4.1.3", 54 | "webpack": "^5.19.0", 55 | "webpack-cli": "^4.4.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /integration_test/proto/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1beta1 2 | plugins: 3 | - name: go 4 | out: ../go/_proto 5 | opt: 6 | - plugins=grpc 7 | - name: js 8 | out: ../ts/_proto 9 | opt: 10 | - import_style=commonjs 11 | - binary 12 | - name: ts 13 | out: ../ts/_proto 14 | path: ../node_modules/.bin/protoc-gen-ts 15 | opt: 16 | - service=grpc-web 17 | -------------------------------------------------------------------------------- /integration_test/proto/improbable/grpcweb/test/test.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package improbable.grpcweb.test; 4 | option go_package = "improbable/grpcweb/test"; 5 | 6 | import "google/protobuf/empty.proto"; 7 | 8 | message PingRequest { 9 | enum FailureType { 10 | NONE = 0; 11 | CODE = 1; 12 | reserved 2; // DROP has been removed 13 | CODE_UNICODE = 3; 14 | } 15 | 16 | string value = 1; 17 | int32 response_count = 2; 18 | uint32 error_code_returned = 3; 19 | FailureType failure_type = 4; 20 | bool check_metadata = 5; 21 | bool send_headers = 6; 22 | bool send_trailers = 7; 23 | 24 | string stream_identifier = 8; 25 | } 26 | 27 | message PingResponse { 28 | string Value = 1; 29 | int32 counter = 2; 30 | } 31 | 32 | message TextMessage { 33 | string text = 1; 34 | bool send_headers = 2; 35 | bool send_trailers = 3; 36 | } 37 | 38 | service TestService { 39 | rpc PingEmpty(google.protobuf.Empty) returns (PingResponse) {} 40 | 41 | rpc Ping(PingRequest) returns (PingResponse) {} 42 | 43 | rpc PingError(PingRequest) returns (google.protobuf.Empty) {} 44 | 45 | rpc PingList(PingRequest) returns (stream PingResponse) {} 46 | 47 | rpc PingPongBidi(stream PingRequest) returns (stream PingResponse) {} 48 | 49 | rpc PingStream(stream PingRequest) returns (PingResponse) {} 50 | 51 | rpc Echo(TextMessage) returns (TextMessage) {} 52 | } 53 | 54 | message ContinueStreamRequest { 55 | string stream_identifier = 1; 56 | } 57 | 58 | message CheckStreamClosedRequest { 59 | string stream_identifier = 1; 60 | } 61 | 62 | message CheckStreamClosedResponse { 63 | bool closed = 1; 64 | } 65 | 66 | service TestUtilService { 67 | rpc ContinueStream(ContinueStreamRequest) returns (google.protobuf.Empty) {} 68 | 69 | rpc CheckStreamClosed(CheckStreamClosedRequest) returns (CheckStreamClosedResponse) {} 70 | } 71 | 72 | service FailService { 73 | rpc NonExistant(PingRequest) returns (PingResponse) {} 74 | } 75 | -------------------------------------------------------------------------------- /integration_test/run-with-testserver.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | set -x 4 | 5 | function killGoTestServer { 6 | echo "Killing testserver..." 7 | killall testserver 8 | } 9 | 10 | ./start-testserver.sh 11 | 12 | # Kill the Go Test server when this script exits or is interrupted. 13 | trap killGoTestServer SIGINT 14 | trap killGoTestServer EXIT 15 | 16 | $@ 17 | -------------------------------------------------------------------------------- /integration_test/run-with-tunnel.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | set -x 4 | 5 | if [[ -z "${SAUCELABS_USERNAME}" ]]; then 6 | echo "No SauceLabs credentials in ENV to use for external browser testing. Use 'npm run test:dev' to do local browser testing." 7 | exit 1 8 | fi 9 | 10 | cd "$(dirname "$0")" 11 | 12 | mkdir -p sauce-connect-proxy/logs 13 | pushd sauce-connect-proxy 14 | 15 | wait_file() { 16 | local file="$1"; shift 17 | local wait_seconds="${1:-10}"; shift 18 | until test $((wait_seconds--)) -eq 0 -o -e "$file" ; do sleep 1; done 19 | ((++wait_seconds)) 20 | } 21 | 22 | BASE_SAUCELABS_TUNNEL_ID=$(openssl rand -base64 12) 23 | export SAUCELABS_TUNNEL_ID_NO_SSL_BUMP="$BASE_SAUCELABS_TUNNEL_ID-no-ssl-bump" 24 | export SAUCELABS_TUNNEL_ID_WITH_SSL_BUMP="$BASE_SAUCELABS_TUNNEL_ID-with-ssl-bump" 25 | 26 | SAUCELABS_READY_FILE_NO_SSL_BUMP=./sauce-connect-readyfile-no-ssl-bump 27 | SAUCELABS_READY_FILE_WITH_SSL_BUMP=./sauce-connect-readyfile-with-ssl-bump 28 | # Clear the ready files in case they already exist 29 | rm -f $SAUCELABS_READY_FILE_WITH_SSL_BUMP $SAUCELABS_READY_FILE_NO_SSL_BUMP 30 | 31 | if [[ "$OSTYPE" == "linux-gnu"* ]]; then 32 | SAUCELABS_TUNNEL_PATH=./sc-4.6.3-linux/bin/sc 33 | if [[ ! -f "$SAUCELABS_TUNNEL_PATH" ]]; then 34 | wget https://saucelabs.com/downloads/sc-4.6.3-linux.tar.gz 35 | tar -xzvf ./sc-4.6.3-linux.tar.gz 36 | fi 37 | elif [[ "$OSTYPE" == "darwin"* ]]; then 38 | SAUCELABS_TUNNEL_PATH=./sc-4.6.3-osx/bin/sc 39 | if [[ ! -f "$SAUCELABS_TUNNEL_PATH" ]]; then 40 | wget https://saucelabs.com/downloads/sc-4.6.3-osx.zip 41 | unzip ./sc-4.6.3-osx.zip 42 | fi 43 | else 44 | echo "Unsupported platform" 45 | fi 46 | 47 | if [[ -z "${SC_SSL_BUMPING}" ]]; then 48 | $SAUCELABS_TUNNEL_PATH \ 49 | -u $SAUCELABS_USERNAME \ 50 | -k $SAUCELABS_ACCESS_KEY \ 51 | --logfile ./logs/saucelabs-no-ssl-bump-logs \ 52 | --pidfile ./saucelabs-no-ssl-bump-pid \ 53 | --no-ssl-bump-domains testhost,corshost \ 54 | --tunnel-identifier $SAUCELABS_TUNNEL_ID_NO_SSL_BUMP \ 55 | --readyfile $SAUCELABS_READY_FILE_NO_SSL_BUMP \ 56 | -x https://saucelabs.com/rest/v1 & 57 | SAUCELABS_PROCESS_ID_NO_SSL_BUMP=$! 58 | echo "SAUCELABS_PROCESS_ID_NO_SSL_BUMP:" 59 | echo $SAUCELABS_PROCESS_ID_NO_SSL_BUMP 60 | fi 61 | 62 | if [[ ! -z "${SC_SSL_BUMPING}" ]]; then 63 | $SAUCELABS_TUNNEL_PATH \ 64 | -u $SAUCELABS_USERNAME \ 65 | -k $SAUCELABS_ACCESS_KEY \ 66 | --logfile ./logs/saucelabs-with-ssl-bump-logs \ 67 | --pidfile ./saucelabs-with-ssl-bump-pid \ 68 | --tunnel-identifier $SAUCELABS_TUNNEL_ID_WITH_SSL_BUMP \ 69 | --readyfile $SAUCELABS_READY_FILE_WITH_SSL_BUMP \ 70 | -x https://saucelabs.com/rest/v1 & 71 | SAUCELABS_PROCESS_ID_WITH_SSL_BUMP=$! 72 | echo "SAUCELABS_PROCESS_ID_WITH_SSL_BUMP:" 73 | echo $SAUCELABS_PROCESS_ID_WITH_SSL_BUMP 74 | fi 75 | 76 | function killTunnels { 77 | echo "Killing Sauce Labs Tunnels..." 78 | if [[ -z "${SC_SSL_BUMPING}" ]]; then 79 | kill $SAUCELABS_PROCESS_ID_NO_SSL_BUMP 80 | fi 81 | if [[ ! -z "${SC_SSL_BUMPING}" ]]; then 82 | kill $SAUCELABS_PROCESS_ID_WITH_SSL_BUMP 83 | fi 84 | } 85 | 86 | trap killTunnels SIGINT 87 | trap killTunnels EXIT 88 | 89 | # Wait for tunnels to indicate ready status 90 | if [[ -z "${SC_SSL_BUMPING}" ]]; then 91 | wait_file "$SAUCELABS_READY_FILE_NO_SSL_BUMP" 60 || { 92 | echo "Timed out waiting for sauce labs tunnel (with ssl bump)" 93 | kill $SAUCELABS_PROCESS_ID_NO_SSL_BUMP 94 | exit 1 95 | } 96 | echo "SAUCELABS_TUNNEL_ID_NO_SSL_BUMP: $SAUCELABS_TUNNEL_ID_NO_SSL_BUMP" 97 | fi 98 | 99 | if [[ ! -z "${SC_SSL_BUMPING}" ]]; then 100 | wait_file "$SAUCELABS_READY_FILE_WITH_SSL_BUMP" 60 || { 101 | echo "Timed out waiting for sauce labs tunnel (no ssl bump)" 102 | kill $SAUCELABS_PROCESS_ID_WITH_SSL_BUMP 103 | exit 1 104 | } 105 | echo "SAUCELABS_TUNNEL_ID_WITH_SSL_BUMP: $SAUCELABS_TUNNEL_ID_WITH_SSL_BUMP" 106 | fi 107 | 108 | popd 109 | 110 | # Run the specified commands 111 | $@ 112 | -------------------------------------------------------------------------------- /integration_test/start-testserver.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | set -x 4 | 5 | cd "$(dirname "$0")" 6 | 7 | if [ -z "$PREBUILT_INTEGRATION_TESTS" ]; then 8 | echo "Building integration test server" 9 | go build -o ./go/build/testserver ./go/testserver/testserver.go 10 | else 11 | echo "Skipping test server build because PREBUILT_INTEGRATION_TESTS is set" 12 | fi 13 | 14 | ./go/build/testserver --tls_cert_file=../misc/localhost.crt --tls_key_file=../misc/localhost.key & 15 | -------------------------------------------------------------------------------- /integration_test/test-ci.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | set -x 4 | 5 | COMMAND="npm run test:browsers" 6 | if [ "$BROWSER" == "nodejs" ]; then 7 | COMMAND="npm run test:node" 8 | fi 9 | 10 | # Avoid rebuilding the go test server 11 | export PREBUILT_INTEGRATION_TESTS=1 12 | 13 | # Run the integration tests with timestamped output 14 | bash -o pipefail -c "${COMMAND} | ts -s %.s" 15 | -------------------------------------------------------------------------------- /integration_test/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set +o pipefail 4 | 5 | npm run build 6 | npm run test:node 7 | npm run test:browsers 8 | -------------------------------------------------------------------------------- /integration_test/ts/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ "env", 4 | { 5 | "targets": { 6 | "ie": "11", 7 | "safari": "8" 8 | } 9 | } 10 | ] 11 | ] 12 | } -------------------------------------------------------------------------------- /integration_test/ts/node-src/node.spec.ts: -------------------------------------------------------------------------------- 1 | // Allow Node to accept the self-signed certificate 2 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; 3 | // Disable the CORS-related tests as they don't apply for Node environments (no origin) 4 | (global as any).DISABLE_CORS_TESTS = true; 5 | // Disable the WebSocket-based tests as they don't apply for Node environments 6 | (global as any).DISABLE_WEBSOCKET_TESTS = true; 7 | 8 | import "../src/spec"; 9 | -------------------------------------------------------------------------------- /integration_test/ts/node-src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "alwaysStrict": true, 5 | "sourceMap": true, 6 | "target": "es5", 7 | "removeComments": true, 8 | "noImplicitReturns": true, 9 | "noImplicitAny": true, 10 | "strictNullChecks": true, 11 | "stripInternal": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "outDir": "../build-node", 14 | "noEmitOnError": true 15 | }, 16 | "types": [ 17 | "jasmine", 18 | "node" 19 | ], 20 | "include": [ 21 | "./node.spec.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /integration_test/ts/src/ChunkParser.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "chai"; 2 | import { decodeASCII, encodeASCII } from "../../../client/grpc-web/src/ChunkParser"; 3 | 4 | describe("ChunkParser", () => { 5 | describe("decodeASCII", () => { 6 | it("should allow valid HTTP headers around", () => { 7 | assert.equal(decodeASCII(new Uint8Array([ 8 | 85, 115, 101, 114, 45, 65, 103, 101, 110, 116, 58, 32, 77, 111, 122, 105, 108, 108, 97, 47, 53, 46, 48, 32, 9 | 40, 87, 105, 110, 100, 111, 119, 115, 32, 78, 84, 32, 49, 48, 46, 48, 59, 32, 87, 105, 110, 54, 52, 59, 32, 10 | 120, 54, 52, 59, 32, 114, 118, 58, 53, 55, 46, 48, 41, 32, 71, 101, 99, 107, 111, 47, 50, 48, 49, 48, 48, 49, 11 | 48, 49, 32, 70, 105, 114, 101, 102, 111, 120, 47, 53, 55, 46, 48, 13, 10 12 | ])), 13 | "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:57.0) Gecko/20100101 Firefox/57.0\r\n"); 14 | }); 15 | 16 | it("should allow quoted headers", () => { 17 | assert.equal(decodeASCII(new Uint8Array([ 18 | 97, 108, 116, 45, 115, 118, 99, 58, 32, 104, 113, 61, 34, 58, 52, 52, 51, 34, 59, 32, 109, 97, 61, 50, 53, 57, 19 | 50, 48, 48, 48, 59, 32, 113, 117, 105, 99, 61, 53, 49, 51, 48, 51, 52, 51, 49, 59, 32, 113, 117, 105, 99, 61, 20 | 53, 49, 51, 48, 51, 51, 51, 57, 59, 32, 113, 117, 105, 99, 61, 53, 49, 51, 48, 51, 51, 51, 56, 59, 32, 113, 21 | 117, 105, 99, 61, 53, 49, 51, 48, 51, 51, 51, 55, 59, 32, 113, 117, 105, 99, 61, 53, 49, 51, 48, 51, 51, 51, 22 | 53, 44, 113, 117, 105, 99, 61, 34, 58, 52, 52, 51, 34, 59, 32, 109, 97, 61, 50, 53, 57, 50, 48, 48, 48, 59, 23 | 32, 118, 61, 34, 52, 49, 44, 51, 57, 44, 51, 56, 44, 51, 55, 44, 51, 53, 34, 13, 10 24 | ])), 25 | `alt-svc: hq=":443"; ma=2592000; quic=51303431; quic=51303339; quic=51303338; quic=51303337; quic=51303335,quic=":443"; ma=2592000; v="41,39,38,37,35"\r\n` 26 | ); 27 | }); 28 | 29 | it("should reject non-encoded Unicode characters", () => { 30 | assert.throw(() => decodeASCII(Uint8Array.from([0xe3, 0x81, 0x82]))); // 'あ' 31 | }) 32 | }); 33 | 34 | describe("encodeASCII", () => { 35 | it("should allow valid HTTP headers around", () => { 36 | assert.deepEqual(encodeASCII(`User-Agent: Mozilla/5.0\r\n`), 37 | new Uint8Array([ 38 | 85, 115, 101, 114, 45, 65, 103, 101, 110, 116, 58, 32, 77, 111, 122, 105, 108, 108, 97, 47, 53, 46, 48, 13, 10 39 | ]) 40 | ); 41 | }); 42 | 43 | it("should allow quoted headers", () => { 44 | assert.deepEqual(encodeASCII(`alt-svc: hq=":443";\r\n`), 45 | new Uint8Array([ 46 | 97, 108, 116, 45, 115, 118, 99, 58, 32, 104, 113, 61, 34, 58, 52, 52, 51, 34, 59, 13, 10 47 | ]) 48 | ); 49 | }); 50 | 51 | it("should reject non-encoded Unicode characters", () => { 52 | assert.throw(() => encodeASCII(`あ`)); 53 | }) 54 | }); 55 | 56 | describe("decodeASCII-encodeASCII", () => { 57 | function testInterop(testCase: string) { 58 | assert.equal(testCase, decodeASCII(encodeASCII(testCase))); 59 | } 60 | 61 | it("should allow valid HTTP headers around", () => { 62 | testInterop(`User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:57.0) Gecko/20100101 Firefox/57.0\r\n`); 63 | }); 64 | 65 | it("should allow quoted headers", () => { 66 | testInterop(`alt-svc: hq=":443"; ma=2592000; quic=51303431; quic=51303339; quic=51303338; quic=51303337; quic=51303335,quic=":443"; ma=2592000; v="41,39,38,37,35"\r\n`); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /integration_test/ts/src/cancellation.spec.ts: -------------------------------------------------------------------------------- 1 | // gRPC-Web library 2 | import { 3 | grpc, 4 | } from "@improbable-eng/grpc-web"; 5 | 6 | import {debug} from "../../../client/grpc-web/src/debug"; 7 | import {assert} from "chai"; 8 | 9 | // Generated Test Classes 10 | import { 11 | CheckStreamClosedRequest, CheckStreamClosedResponse, 12 | PingRequest, 13 | PingResponse, 14 | } from "../_proto/improbable/grpcweb/test/test_pb"; 15 | import {TestService, TestUtilService} from "../_proto/improbable/grpcweb/test/test_pb_service"; 16 | import {DEBUG, continueStream} from "./util"; 17 | import { runWithHttp1AndHttp2, runWithSupportedTransports } from "./testRpcCombinations"; 18 | 19 | describe("Cancellation", () => { 20 | runWithHttp1AndHttp2(({testHostUrl}) => { 21 | it("should allow the caller to abort an rpc before it completes", () => { 22 | let transportCancelFuncInvoked = false; 23 | 24 | const cancellationSpyTransport = () => { 25 | return { 26 | sendMessage: () => { 27 | }, 28 | finishSend() { 29 | }, 30 | start: () => { 31 | }, 32 | cancel: () => { 33 | transportCancelFuncInvoked = true; 34 | }, 35 | } 36 | }; 37 | 38 | const ping = new PingRequest(); 39 | ping.setValue("hello world"); 40 | 41 | const reqObj = grpc.invoke(TestService.Ping, { 42 | debug: DEBUG, 43 | request: ping, 44 | host: testHostUrl, 45 | transport: cancellationSpyTransport, 46 | onEnd: (status: grpc.Code, statusMessage: string, trailers: grpc.Metadata) => { 47 | }, 48 | }); 49 | 50 | reqObj.close(); 51 | 52 | assert.equal(transportCancelFuncInvoked, true, "transport's cancel func must be invoked"); 53 | }); 54 | 55 | runWithSupportedTransports((transport) => { 56 | it("should handle aborting a streaming response mid-stream with propagation of the disconnection to the server", (done) => { 57 | let onMessageId = 0; 58 | 59 | const streamIdentifier = `rpc-${Math.random()}`; 60 | 61 | const ping = new PingRequest(); 62 | ping.setValue("hello world"); 63 | ping.setResponseCount(100); // Request more messages than the client will accept before cancelling 64 | ping.setStreamIdentifier(streamIdentifier); 65 | 66 | let reqObj: grpc.Request; 67 | 68 | // Checks are performed every 1s = 15s total wait 69 | const maxAbortChecks = 15; 70 | 71 | const numMessagesBeforeAbort = 5; 72 | 73 | const doAbort = () => { 74 | DEBUG && debug("doAbort"); 75 | reqObj.close(); 76 | 77 | // To ensure that the transport is successfully closing the connection, poll the server every 1s until 78 | // it confirms the connection was closed. Connection closure is immediate in some browser/transport combinations, 79 | // but can take several seconds in others. 80 | function checkAbort(attempt: number) { 81 | DEBUG && debug("checkAbort", attempt); 82 | continueStream(testHostUrl, streamIdentifier, (status) => { 83 | DEBUG && debug("checkAbort.continueStream.status", status); 84 | 85 | const checkStreamClosedRequest = new CheckStreamClosedRequest(); 86 | checkStreamClosedRequest.setStreamIdentifier(streamIdentifier); 87 | grpc.unary(TestUtilService.CheckStreamClosed, { 88 | debug: DEBUG, 89 | request: checkStreamClosedRequest, 90 | host: testHostUrl, 91 | onEnd: ({message}) => { 92 | const closed = ( message as CheckStreamClosedResponse ).getClosed(); 93 | DEBUG && debug("closed", closed); 94 | if (closed) { 95 | done(); 96 | } else { 97 | if (attempt >= maxAbortChecks) { 98 | assert.ok(closed, `server did not observe connection closure within ${maxAbortChecks} seconds`); 99 | done(); 100 | } else { 101 | setTimeout(() => { 102 | checkAbort(attempt + 1); 103 | }, 1000); 104 | } 105 | } 106 | }, 107 | }) 108 | }); 109 | } 110 | 111 | checkAbort(0); 112 | }; 113 | 114 | reqObj = grpc.invoke(TestService.PingList, { 115 | debug: DEBUG, 116 | request: ping, 117 | host: testHostUrl, 118 | transport: transport, 119 | onHeaders: (headers: grpc.Metadata) => { 120 | DEBUG && debug("headers", headers); 121 | }, 122 | onMessage: (message: PingResponse) => { 123 | assert.ok(message instanceof PingResponse); 124 | DEBUG && debug("onMessage.message.getCounter()", message.getCounter()); 125 | assert.strictEqual(message.getCounter(), onMessageId++); 126 | if (message.getCounter() === numMessagesBeforeAbort) { 127 | // Abort after receiving numMessagesBeforeAbort messages 128 | doAbort(); 129 | } else if (message.getCounter() < numMessagesBeforeAbort) { 130 | // Only request the next message if not yet aborted 131 | continueStream(testHostUrl, streamIdentifier, (status) => { 132 | DEBUG && debug("onMessage.continueStream.status", status); 133 | }); 134 | } 135 | }, 136 | onEnd: (status: grpc.Code, statusMessage: string, trailers: grpc.Metadata) => { 137 | DEBUG && debug("status", status, "statusMessage", statusMessage, "trailers", trailers); 138 | // onEnd shouldn't be called if abort is called prior to the response ending 139 | assert.fail(); 140 | } 141 | }); 142 | }, 20000); 143 | }) 144 | }); 145 | }); -------------------------------------------------------------------------------- /integration_test/ts/src/spec.ts: -------------------------------------------------------------------------------- 1 | import "./client.spec"; 2 | import "./client.websocket.spec"; 3 | import "./invoke.spec"; 4 | import "./unary.spec"; 5 | import "./cancellation.spec"; 6 | import "./ChunkParser.spec"; 7 | 8 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; 9 | -------------------------------------------------------------------------------- /integration_test/ts/src/testRpcCombinations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | testHost, 3 | corsHost 4 | } from "../../hosts-config"; 5 | import {grpc} from "@improbable-eng/grpc-web"; 6 | import {NodeHttpTransport} from "@improbable-eng/grpc-web-node-http-transport"; 7 | import { DISABLE_WEBSOCKET_TESTS } from "./util"; 8 | 9 | type TestConfig = { 10 | testHostUrl: string, 11 | corsHostUrl: string, 12 | unavailableHost: string, 13 | emptyHost: string, 14 | httpVersion: string, 15 | } 16 | 17 | export function headerTrailerCombos(cb: (withHeaders: boolean, withTrailers: boolean) => void) { 18 | describe("(no headers - no trailers)", () => { 19 | cb(false, false); 20 | }); 21 | describe("(with headers - no trailers)", () => { 22 | cb(true, false); 23 | }); 24 | describe("(no headers - with trailers)", () => { 25 | cb(false, true); 26 | }); 27 | describe("(with headers - with trailers)", () => { 28 | cb(true, true); 29 | }); 30 | } 31 | 32 | const http1Config: TestConfig = { 33 | testHostUrl: `https://${testHost}:9100`, 34 | corsHostUrl: `https://${corsHost}:9100`, 35 | unavailableHost: `https://${testHost}:9999`, 36 | emptyHost: `https://${corsHost}:9105`, 37 | httpVersion: "http1", 38 | }; 39 | 40 | const http2Config: TestConfig = { 41 | testHostUrl: `https://${testHost}:9090`, 42 | corsHostUrl: `https://${corsHost}:9090`, 43 | unavailableHost: `https://${testHost}:9999`, 44 | emptyHost: `https://${corsHost}:9095`, 45 | httpVersion: "http2", 46 | }; 47 | 48 | export function runWithHttp1AndHttp2(cb: (config: TestConfig) => void) { 49 | describe("(http1)", () => { 50 | cb(http1Config); 51 | }); 52 | describe("(http2)", () => { 53 | cb(http2Config); 54 | }); 55 | } 56 | 57 | export function runWithSupportedTransports(cb: (transport: grpc.TransportFactory | undefined) => void) { 58 | const transports: {[key: string]: grpc.TransportFactory | undefined} = { 59 | "httpTransport": undefined 60 | }; 61 | 62 | if (process.env.BROWSER === "nodejs") { 63 | grpc.setDefaultTransport(NodeHttpTransport()); 64 | } 65 | 66 | if (!DISABLE_WEBSOCKET_TESTS) { 67 | transports["websocketTransport"] = grpc.WebsocketTransport(); 68 | } 69 | 70 | for (let transportName in transports) { 71 | describe(transportName, () => { 72 | cb(transports[transportName]); 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /integration_test/ts/src/util.ts: -------------------------------------------------------------------------------- 1 | import {ContinueStreamRequest} from "../_proto/improbable/grpcweb/test/test_pb"; 2 | import {TestUtilService} from "../_proto/improbable/grpcweb/test/test_pb_service"; 3 | import { 4 | grpc, 5 | } from "@improbable-eng/grpc-web"; 6 | 7 | export const DEBUG: boolean = (global as any).DEBUG; 8 | export const DISABLE_CORS_TESTS: boolean = (global as any).DISABLE_CORS_TESTS; 9 | export const DISABLE_WEBSOCKET_TESTS: boolean = (global as any).DISABLE_WEBSOCKET_TESTS; 10 | 11 | export class UncaughtExceptionListener { 12 | private attached: boolean = false; 13 | private exceptionsCaught: string[] = []; 14 | private originalWindowHandler?: OnErrorEventHandler; 15 | private originalProcessHandlers: Function[] = []; 16 | private processListener: (err: Error) => void; 17 | 18 | constructor() { 19 | const self = this; 20 | 21 | self.originalWindowHandler = typeof window !== "undefined" ? window.onerror : undefined; 22 | self.processListener = (err: Error) => { 23 | self.exceptionsCaught.push(err.message); 24 | }; 25 | } 26 | 27 | attach() { 28 | const self = this; 29 | 30 | if (self.attached) { 31 | return; 32 | } 33 | if (typeof window !== "undefined") { 34 | window.onerror = function (message: string) { 35 | self.exceptionsCaught.push(message); 36 | }; 37 | } else { 38 | // Remove existing listeners - necessary to prevent test runners from exiting on exceptions 39 | self.originalProcessHandlers = process.listeners("uncaughtException"); 40 | process.removeAllListeners("uncaughtException"); 41 | process.addListener("uncaughtException", self.processListener); 42 | } 43 | self.attached = true; 44 | } 45 | 46 | detach() { 47 | const self = this; 48 | 49 | if (!self.attached) { 50 | return; 51 | } 52 | if (typeof window !== "undefined") { 53 | window.onerror = self.originalWindowHandler!; 54 | } else { 55 | process.removeListener("uncaughtException", self.processListener); 56 | self.originalProcessHandlers.forEach((handler: (error: Error) => void) => { 57 | process.addListener("uncaughtException", handler); 58 | }); 59 | self.originalProcessHandlers = []; 60 | } 61 | self.attached = false; 62 | } 63 | 64 | getMessages() { 65 | return this.exceptionsCaught; 66 | } 67 | } 68 | 69 | export function continueStream(host: string, streamIdentifier: string, cb: (status: grpc.Code) => void) { 70 | const req = new ContinueStreamRequest(); 71 | req.setStreamIdentifier(streamIdentifier); 72 | grpc.unary(TestUtilService.ContinueStream, { 73 | debug: DEBUG, 74 | request: req, 75 | host: host, 76 | onEnd: ({status}) => { 77 | cb(status); 78 | }, 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /integration_test/ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "sourceMap": true, 5 | "target": "es5", 6 | "removeComments": true, 7 | "noImplicitReturns": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "stripInternal": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "outDir": "build", 13 | "noEmitOnError": true 14 | }, 15 | "types": [ 16 | "jasmine", 17 | "node" 18 | ], 19 | "include": [ 20 | "./src" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /integration_test/ts/webpack.config.ts: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | module.exports = { 4 | entry: "./src/spec.ts", 5 | mode: "development", 6 | target: "es5", 7 | output: { 8 | path: path.resolve(__dirname, "build"), 9 | filename: "integration-tests.js" 10 | }, 11 | plugins: [ 12 | new webpack.DefinePlugin({ 13 | "process.env": JSON.stringify(process.env), 14 | }), 15 | ], 16 | devtool: "source-map", 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.js$/, 21 | include: /src/, 22 | loader: "babel-loader", 23 | exclude: /node_modules/ 24 | }, 25 | { 26 | test: /\.ts$/, 27 | include: [/src/, /_proto/], 28 | exclude: /node_modules/, 29 | loader: "ts-loader" 30 | } 31 | ] 32 | }, 33 | resolve: { 34 | fallback: { 35 | "http": false, 36 | "https": false, 37 | "url": false, 38 | }, 39 | extensions: [".ts", ".js"] 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "client/*", 4 | "integration_test" 5 | ], 6 | "version": "independent" 7 | } 8 | -------------------------------------------------------------------------------- /lint-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Linting go sources" 4 | cd go && . ./lint.sh && cd .. 5 | 6 | echo "Linting TypeScript sources" 7 | npm run lint 8 | 9 | echo "Linting protobuf sources" 10 | buf lint 11 | -------------------------------------------------------------------------------- /misc/gen_cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Regenerate the self-signed certificate for local host. Recent versions of firefox and chrome(ium) 3 | # require a certificate authority to be imported by the browser (localhostCA.pem) while 4 | # the server uses a cert and key signed by that certificate authority. 5 | # Based partly on https://stackoverflow.com/a/48791236 6 | CA_PASSWORD=notsafe 7 | 8 | # Generate the root certificate authority key with the set password 9 | openssl genrsa -des3 -passout pass:$CA_PASSWORD -out localhostCA.key 2048 10 | 11 | # Generate a root-certificate based on the root-key for importing to browsers. 12 | openssl req -x509 -new -nodes -key localhostCA.key -passin pass:$CA_PASSWORD -config localhostCA.conf -sha256 -days 1825 -out localhostCA.pem 13 | 14 | # Generate a new private key 15 | openssl genrsa -out localhost.key 2048 16 | 17 | # Generate a Certificate Signing Request (CSR) based on that private key (reusing the 18 | # localhostCA.conf details) 19 | openssl req -new -key localhost.key -out localhost.csr -config localhostCA.conf 20 | 21 | # Create the certificate for the webserver to serve using the localhost.conf config. 22 | openssl x509 -req -in localhost.csr -CA localhostCA.pem -CAkey localhostCA.key -CAcreateserial \ 23 | -out localhost.crt -days 1024 -sha256 -extfile localhost.conf -passin pass:$CA_PASSWORD 24 | -------------------------------------------------------------------------------- /misc/localhost.conf: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier=keyid,issuer 2 | basicConstraints=CA:FALSE 3 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 4 | subjectAltName = @alt_names 5 | 6 | [alt_names] 7 | DNS.1 = localhost 8 | DNS.2 = testhost 9 | DNS.3 = corshost 10 | -------------------------------------------------------------------------------- /misc/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFODCCBCCgAwIBAgIJAMmbhUg0bRR9MA0GCSqGSIb3DQEBCwUAMIHDMQswCQYD 3 | VQQGEwJVUzETMBEGA1UECAwKR1JQQyBTdGF0ZTESMBAGA1UEBwwJR1JQQyBUb3du 4 | MSgwJgYDVQQKDB9HUlBDIFdlYiBsb2NhbGhvc3QgT3JnYW5pc2F0aW9uMRYwFAYD 5 | VQQLDA1PcmcgVW5pdCBOYW1lMSQwIgYDVQQDDBtHUlBDIFdlYiBleGFtcGxlIGRl 6 | diBzZXJ2ZXIxIzAhBgkqhkiG9w0BCQEWFGdycGMtd2ViQGV4YW1wbGUuY29tMB4X 7 | DTIxMTAyNzE5NDgyMFoXDTI0MDgxNjE5NDgyMFowgcMxCzAJBgNVBAYTAlVTMRMw 8 | EQYDVQQIDApHUlBDIFN0YXRlMRIwEAYDVQQHDAlHUlBDIFRvd24xKDAmBgNVBAoM 9 | H0dSUEMgV2ViIGxvY2FsaG9zdCBPcmdhbmlzYXRpb24xFjAUBgNVBAsMDU9yZyBV 10 | bml0IE5hbWUxJDAiBgNVBAMMG0dSUEMgV2ViIGV4YW1wbGUgZGV2IHNlcnZlcjEj 11 | MCEGCSqGSIb3DQEJARYUZ3JwYy13ZWJAZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3 12 | DQEBAQUAA4IBDwAwggEKAoIBAQC66Y3vZznzPL9bscrvRch7dzzD538XlnYlcfLp 13 | rYYmOeWW1vM+/+KTqgb4GuKqZq6rGnhNEoQhRH0i4kZa7VgvY+mvFqGPrnjPOkYQ 14 | S2GoemwKIUqPapJtB8pM6o7hdycKYNn3QvdORmKduWOYzPLriHkHEAEH1zK9UvkO 15 | kGM9T2U0xMBATk9V/M/V18psKG90tndit+3+vN384yVR5KL0i9wQBAQWYa/IMF6V 16 | Q8n2hoJbED2pdYkQFeymTBMIqr7g7MkK0im6ZqW2MDIrn6rkjyd8odlXuaRLAPGp 17 | mV7Ii5JK2eyK+rH2QCb7slGl6nHpadYD4PEvd3ajlyzJdH3/AgMBAAGjggErMIIB 18 | JzCB4gYDVR0jBIHaMIHXoYHJpIHGMIHDMQswCQYDVQQGEwJVUzETMBEGA1UECAwK 19 | R1JQQyBTdGF0ZTESMBAGA1UEBwwJR1JQQyBUb3duMSgwJgYDVQQKDB9HUlBDIFdl 20 | YiBsb2NhbGhvc3QgT3JnYW5pc2F0aW9uMRYwFAYDVQQLDA1PcmcgVW5pdCBOYW1l 21 | MSQwIgYDVQQDDBtHUlBDIFdlYiBleGFtcGxlIGRldiBzZXJ2ZXIxIzAhBgkqhkiG 22 | 9w0BCQEWFGdycGMtd2ViQGV4YW1wbGUuY29tggkA/2+s4xt7kvEwCQYDVR0TBAIw 23 | ADALBgNVHQ8EBAMCBPAwKAYDVR0RBCEwH4IJbG9jYWxob3N0ggh0ZXN0aG9zdIII 24 | Y29yc2hvc3QwDQYJKoZIhvcNAQELBQADggEBADWb8cis27F7benX5KJWwNX4C9Fb 25 | YDCsOWGNW3U2Q/gcdNkBl5ovu9nfskRGRDFk0x+2tQEaE1PEwCDWrhTnAqbTwdBn 26 | ZRslmzVBdDTD4p/R4DEJIk1/tWiiTZnF7/ZJypvBkqLHnYMi7Dpi6587G4iruCUN 27 | 1gD46j1Eh0wsMNJY0J9uaXtkgRg5yR7fCPw4E1tmC0tqlW9zKGuF2moN1p4cPjvL 28 | ZUg3/0y/xq0ckM14PnyQ1HLrt3cxEUI649L2iVaE9nasXnb3bU4YJFvFwjpeP+Rc 29 | 9xT0ZhTp8m3juU3wjfWjpJntEQn96xocRFioEaMxpCnHMZ5q6TWDHNTg9hw= 30 | -----END CERTIFICATE----- 31 | -------------------------------------------------------------------------------- /misc/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAuumN72c58zy/W7HK70XIe3c8w+d/F5Z2JXHy6a2GJjnlltbz 3 | Pv/ik6oG+Briqmauqxp4TRKEIUR9IuJGWu1YL2Pprxahj654zzpGEEthqHpsCiFK 4 | j2qSbQfKTOqO4XcnCmDZ90L3TkZinbljmMzy64h5BxABB9cyvVL5DpBjPU9lNMTA 5 | QE5PVfzP1dfKbChvdLZ3Yrft/rzd/OMlUeSi9IvcEAQEFmGvyDBelUPJ9oaCWxA9 6 | qXWJEBXspkwTCKq+4OzJCtIpumaltjAyK5+q5I8nfKHZV7mkSwDxqZleyIuSStns 7 | ivqx9kAm+7JRpepx6WnWA+DxL3d2o5csyXR9/wIDAQABAoIBAQCao5JfaGsEd9Tl 8 | +xGnpnd41qy6c/OtQzmaP0020e6z/6CYjFwRWklN3BUJ/cxcKLoIK80uDsysbWqO 9 | iuCkZ8tW4fW7eyDNrA6dfFvtLUCt0CNEukhioUxl0lUoD8OIfDkbmAedT5Ul+Ius 10 | bG4fRCkSfwWKt03y+7Mp+dS+nOzOElDANlxxZRUjBHx08VPE4/r9L4efFCU3T4Jd 11 | I72IzZeEXXH2R4R4POzBoPFP+LHg1N+JUUtAkArd30Ar3oc4EReWqSqPqMOiMPmU 12 | mwFgP3eAIMP+FPyM5KELMpzz6HHVwm9Kh7DMAwDaEb3IR84uulqsqrg48mKmi2Sr 13 | DmAK0YGZAoGBAOJry2CdxFmj45Ca2eNq1cix+1xhQo0VvIiUeBiJmNUHSX5gQDhQ 14 | 9Kd5X4TR6/DqTK+oHRVDtDs/zxeqP5CSsNpmbz2FBrkjka7SYpZVv3/liczAT3Hy 15 | H67Pvw0yIucmw1Cv7+rsix2YsmGd0EA/6XJik44SJ2lfMpQrFqJiBNMbAoGBANNU 16 | d8bpkMBZEZFWreFiuhWGO5+W9WX+M6b6DMAmtuTjIv7NMiVpxs9r7ymUO5mt2vfQ 17 | LRxK3X+MpYPcUBsRhF5bu0WTXgKW/VL84WyzSYvlT8EENKCtfSYsnUZYCjOiFUk0 18 | 769E8nGYuXpTDKOIg9Jv45r7PErGgBEOCK/eGgrtAoGBAMfG9raz3Yh+S47Oosu+ 19 | +wxOxgtXoaHcePJFlcWIurnT6SvBf0hxXbzbIcWOd1ClWq5udeLKTx8sCOzHgbht 20 | RfAeC67LTghS8vq+lNAyrnoJrNFlKXPPf9b9ZIQfJZ6wnAr4gYbV2VVu4o2w8guO 21 | mMsdYTYsnGuj3HvRnPH/7GPbAoGAFZawccKUhgHTWJyZQMgcKGzBFImQYi34ytsK 22 | iGqsDm/huFPwBoBqze/By+aXvBhVoTFEGnrPa+NLWVAdYtaERjtqwy3N0cfo8xxg 23 | TwF1xvPTFO3ADpYKjebK3k/KIwIw2Hyu66HIfrBSalunk+EzTkEd6Ew4GY9zr8pW 24 | OtkeofUCgYEAta0CrWkWcBi5SUcMpD9SkTy10ejYb7/BnG+VYr9l/jBO8DCeFuj9 25 | OGMvRaQLWcs9PG7286VkLBMUo2pc+mpTbw6WR3qrDojoiE7P3DUnAzMQTsZe69lD 26 | vXp5yhu9c9O5QpK6uzJ64rVCYE6g+noUe/dJNoMUndPF1RahW5nELTo= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /misc/localhostCA.conf: -------------------------------------------------------------------------------- 1 | [ req ] 2 | prompt = no 3 | distinguished_name = req_distinguished_name 4 | x509_extensions = v3_req 5 | 6 | [ req_distinguished_name ] 7 | C = US 8 | ST = GRPC State 9 | L = GRPC Town 10 | O = GRPC Web localhost Organisation 11 | OU = Org Unit Name 12 | CN = GRPC Web example dev server 13 | emailAddress = grpc-web@example.com 14 | 15 | [ v3_req ] 16 | keyUsage = critical, digitalSignature, keyAgreement 17 | extendedKeyUsage = serverAuth 18 | subjectAltName = @alt_names 19 | 20 | [ alt_names ] 21 | DNS.1 = localhost 22 | DNS.2 = testhost 23 | DNS.3 = corshost -------------------------------------------------------------------------------- /misc/localhostCA.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEXDCCA0SgAwIBAgIJAP9vrOMbe5LxMA0GCSqGSIb3DQEBCwUAMIHDMQswCQYD 3 | VQQGEwJVUzETMBEGA1UECAwKR1JQQyBTdGF0ZTESMBAGA1UEBwwJR1JQQyBUb3du 4 | MSgwJgYDVQQKDB9HUlBDIFdlYiBsb2NhbGhvc3QgT3JnYW5pc2F0aW9uMRYwFAYD 5 | VQQLDA1PcmcgVW5pdCBOYW1lMSQwIgYDVQQDDBtHUlBDIFdlYiBleGFtcGxlIGRl 6 | diBzZXJ2ZXIxIzAhBgkqhkiG9w0BCQEWFGdycGMtd2ViQGV4YW1wbGUuY29tMB4X 7 | DTIxMTAyNzE5NDgyMFoXDTI2MTAyNjE5NDgyMFowgcMxCzAJBgNVBAYTAlVTMRMw 8 | EQYDVQQIDApHUlBDIFN0YXRlMRIwEAYDVQQHDAlHUlBDIFRvd24xKDAmBgNVBAoM 9 | H0dSUEMgV2ViIGxvY2FsaG9zdCBPcmdhbmlzYXRpb24xFjAUBgNVBAsMDU9yZyBV 10 | bml0IE5hbWUxJDAiBgNVBAMMG0dSUEMgV2ViIGV4YW1wbGUgZGV2IHNlcnZlcjEj 11 | MCEGCSqGSIb3DQEJARYUZ3JwYy13ZWJAZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3 12 | DQEBAQUAA4IBDwAwggEKAoIBAQCvifPlvbJS0c5YlzwG7RbIfOoJICk+/jY8tt+B 13 | 3FLE27i5Rl8/ftYpe3wR443K4V4SJ0iCiCfI3SxmGOJeNAtac0sD7DTlE7c/UXOy 14 | c/WQuP0/nShNikJkBNngTHZmWtGmXg/q3FyHhDqLR0KMEaiv/eyUBNteNJHbtOwm 15 | Nc33JUE1udUk0tjkI/Z/L2n0oUUuYN6tAMwmMisT4Qv88Ybk2XpjJEPoqnvBzF2n 16 | llYgbcgHzefFJZtJygMT9D0ORKMwqpiAzxGd5iJHAcn+VQTOb9GQhPmyeDNj6Kmj 17 | M0TVTIBjzvy9Ww11Y9HvVgvYhUEbMjzJSw5bA4BvmSqhxC+7AgMBAAGjUTBPMA4G 18 | A1UdDwEB/wQEAwIDiDATBgNVHSUEDDAKBggrBgEFBQcDATAoBgNVHREEITAfggls 19 | b2NhbGhvc3SCCHRlc3Rob3N0gghjb3JzaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEA 20 | myWvrpQppFWeg2E0PZGo6dR+DAFgkXtCotEhlSOWsU4MfV+IjrxwvbICkQ6nFSF8 21 | 9FFBiXKbhKxGEEWCqlqtXCPCxotypUavqOfeFNHmOHNQW4C3JbYGbHBunqbH4qOY 22 | f2rHvjT0GjPvKcgy0e750amAgoRQWnTNrHZ9ScddvZhwxdRAQrO4OM36D0YRl6yF 23 | MU5Fz1KmHlfp1vPLbUcrXZDil9azis9Iqpxhy4TcUoQJwyeyuGhxzAXW9IqOjBng 24 | aIb1dsOYVGzjn8EWa6v7ZyUYQQ7kRIv8naksLhBarK3mW90Oyxu/4TXi/75EE92k 25 | 3NqYlMOu5fZkhLmfGIUhug== 26 | -----END CERTIFICATE----- 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grpc-web-ci", 3 | "private": true, 4 | "scripts": { 5 | "postinstall": "npm run bootstrap", 6 | "bootstrap": "lerna bootstrap --concurrency 1", 7 | "postbootstrap": "lerna run postbootstrap --concurrency 1", 8 | "clean": "lerna clean --yes --private && lerna run clean", 9 | "test": "lerna run test", 10 | "build:integration": "cd integration_test && npm run build", 11 | "test:integration-browsers:ci": "cd integration_test && npm run start:ci", 12 | "lint": "tslint -c ./tslint.json ./client/**/*.ts ./integration_test/ts/*.ts ./integration_test/ts/**/*.ts" 13 | }, 14 | "author": "Improbable", 15 | "license": "Apache-2.0", 16 | "repository": "github:improbable-eng/grpc-web", 17 | "devDependencies": { 18 | "github-release-cli": "^2.0.0", 19 | "lerna": "^3.22.1", 20 | "tslint": "^6.1.3", 21 | "typescript": "^4.1.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /publish-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [[ -z "$GITHUB_TOKEN" ]]; then 5 | echo "GITHUB_TOKEN not found in environment" 6 | exit 1 7 | fi 8 | 9 | TAG=$1 10 | if [[ -z "$TAG" ]]; then 11 | echo "Expected release tag to be passed as the first argument" 12 | exit 1 13 | fi 14 | 15 | if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 16 | echo "Invalid release tag: $TAG" 17 | exit 1 18 | fi 19 | 20 | echo "Generating proxy binaries" 21 | cd go 22 | rm -rf dist 23 | mkdir -p dist 24 | GOOS=linux GOARCH=amd64 go build -o "dist/grpcwebproxy-$TAG-linux-x86_64" ./grpcwebproxy 25 | GOOS=windows GOARCH=amd64 go build -o "dist/grpcwebproxy-$TAG-win64.exe" ./grpcwebproxy 26 | GOOS=windows GOARCH=386 go build -o "dist/grpcwebproxy-$TAG-win32.exe" ./grpcwebproxy 27 | GOOS=darwin GOARCH=amd64 go build -o "dist/grpcwebproxy-$TAG-osx-x86_64" ./grpcwebproxy 28 | for f in dist/*; do zip -9r "$f.zip" "$f"; done 29 | ls -l ./dist 30 | cd .. 31 | 32 | echo "Generating client binaries" 33 | npm run clean 34 | npm install 35 | 36 | echo "Publishing $TAG" 37 | 38 | # Create github release and attach server binaries 39 | ./node_modules/.bin/github-release upload \ 40 | --owner improbable-eng \ 41 | --repo grpc-web \ 42 | --tag "$TAG" \ 43 | --name "$TAG" \ 44 | --body "See [CHANGELOG](https://github.com/improbable-eng/grpc-web/blob/master/CHANGELOG.md) for details" \ 45 | go/dist/*.zip 46 | 47 | # Publish client modules to NPM. 48 | npx lerna publish $TAG --yes 49 | -------------------------------------------------------------------------------- /test-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Testing go sources" 5 | cd go && go test ./... && cd .. 6 | 7 | echo "Testing TypeScript sources" 8 | npm run test 9 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | package tools 4 | 5 | import ( 6 | _ "github.com/golang/protobuf/protoc-gen-go" 7 | ) 8 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [true, "check-space"], 5 | "indent": [true, "spaces"], 6 | "no-duplicate-variable": true, 7 | "no-eval": true, 8 | "no-internal-module": true, 9 | "no-trailing-whitespace": true, 10 | "no-var-keyword": true, 11 | "one-line": [true, "check-open-brace", "check-whitespace"], 12 | "quotemark": [true, "double"], 13 | "semicolon": false, 14 | "triple-equals": [true, "allow-null-check"], 15 | "typedef-whitespace": [true, { 16 | "call-signature": "nospace", 17 | "index-signature": "nospace", 18 | "parameter": "nospace", 19 | "property-declaration": "nospace", 20 | "variable-declaration": "nospace" 21 | }], 22 | "variable-name": [true, "ban-keywords"], 23 | "whitespace": [true, 24 | "check-branch", 25 | "check-decl", 26 | "check-operator", 27 | "check-separator", 28 | "check-type" 29 | ] 30 | }, 31 | "jsRules": { 32 | "indent": [true, "spaces"], 33 | "no-duplicate-variable": true, 34 | "no-eval": true, 35 | "no-trailing-whitespace": true, 36 | "one-line": [true, "check-open-brace", "check-whitespace"], 37 | "quotemark": [true, "double"], 38 | "semicolon": false, 39 | "triple-equals": [true, "allow-null-check"], 40 | "variable-name": [true, "ban-keywords"], 41 | "whitespace": [true, 42 | "check-branch", 43 | "check-decl", 44 | "check-operator", 45 | "check-separator", 46 | "check-type" 47 | ] 48 | }, 49 | "linterOptions": { 50 | "exclude": [ "**/node_modules/**", "**/_proto/**" ] 51 | } 52 | } --------------------------------------------------------------------------------