├── .eslintignore ├── .eslintrc.cjs ├── .gitattributes ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── e2e └── server │ ├── server.test.ts │ └── testdata │ ├── battles.ts │ ├── date_parse.ts │ ├── date_serialize.ts │ ├── get_secret_key.ts │ ├── hero.ts │ ├── named_interface.ts │ ├── schema.graphql │ ├── search.ts │ ├── search_union.ts │ ├── sidekick.ts │ ├── stucco-js.cmd │ ├── stucco-js.js │ ├── stucco.json │ └── tsconfig.json ├── gulpfile.js ├── jest.config.cjs ├── jest.e2e.config.cjs ├── package-lock.json ├── package.json ├── scripts └── bump_stucco.js ├── src ├── api │ └── index.ts ├── cli │ ├── cli.cmd │ ├── cli.ts │ ├── cmds │ │ ├── azure.ts │ │ ├── azure_cmds │ │ │ └── serve.ts │ │ ├── config.ts │ │ ├── plugin.ts │ │ └── plugin_cmds │ │ │ ├── config.ts │ │ │ └── serve.ts │ └── util.ts ├── handler │ └── index.ts ├── http │ └── handle.ts ├── index.ts ├── proto │ └── driver │ │ ├── directive.ts │ │ ├── driver_service.ts │ │ ├── index.ts │ │ ├── messages.ts │ │ ├── operation.ts │ │ ├── protocol.ts │ │ ├── response_path.ts │ │ ├── selection.ts │ │ ├── source.ts │ │ ├── type_ref.ts │ │ ├── value.ts │ │ └── variable_definition.ts ├── raw │ ├── authorize.ts │ ├── field_resolve.ts │ ├── handler.ts │ ├── index.ts │ ├── interface_resolve_type.ts │ ├── message.ts │ ├── scalar.ts │ ├── set_secrets.ts │ ├── subscription_connection.ts │ └── union_resolve_type.ts ├── security │ ├── apiKey.ts │ ├── cert.ts │ └── index.ts ├── server │ ├── hook_console.ts │ ├── hook_stream.ts │ ├── index.ts │ ├── profiler.ts │ ├── run.ts │ └── server.ts ├── stucco │ ├── cmd.cmd │ ├── cmd.ts │ ├── dirname.cts │ ├── run.ts │ └── version.ts ├── typings │ ├── bin-check.d.ts │ ├── bin-version-check.ts │ ├── bin-wrapper.d.ts │ └── yargs.d.ts └── util │ ├── log.ts │ └── util.ts ├── stucco.jpg ├── tests ├── api │ └── api.test.ts ├── handler │ ├── index.test.ts │ └── testdata │ │ ├── commonjs_default.cjs │ │ ├── commonjs_handler.cjs │ │ ├── commonjs_named.cjs │ │ ├── esm_default.js │ │ ├── esm_dict_named.js │ │ ├── esm_handler.js │ │ ├── esm_named.js │ │ └── node_modules │ │ └── externaltesthandleresm │ │ ├── index.js │ │ └── package.json ├── proto │ └── driver │ │ ├── driver.test.ts │ │ └── value.test.ts ├── raw │ ├── field_resolve.test.ts │ ├── interface_resolve_type.test.ts │ ├── message.test.ts │ ├── scalar.test.ts │ ├── set_secrets.test.ts │ ├── testdata │ │ ├── field_nil_resolve.js │ │ ├── interface_handler.js │ │ ├── scalar_nil_parse.js │ │ ├── scalar_nil_serialize.js │ │ └── union_handler.js │ └── union_resolve_type.test.ts ├── security │ ├── apiKey.test.ts │ ├── cert.test.ts │ └── index.test.ts ├── server │ ├── hook_console.test.ts │ ├── hook_stream.test.ts │ ├── profiler.test.ts │ ├── run.test.ts │ ├── server.test.ts │ └── testdata │ │ ├── field_resolve_handler.js │ │ ├── interface_resolve_type_handler.js │ │ ├── scalar_parse_handler.js │ │ ├── scalar_serialize_handler.js │ │ ├── union_resolve_type_handler.js │ │ └── user_error.js └── stucco │ ├── run.test.ts │ └── version.test.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | src/proto/driver_grpc_pb.d.ts 2 | src/proto/driver_pb.d.ts 3 | src/proto/driver_pb.js 4 | src/proto/driver_grpc_pb.js 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | jest: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array., 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 13 | sourceType: 'module', // Allows for the use of imports 14 | }, 15 | plugins: ['@typescript-eslint', 'prettier'], 16 | overrides: [ 17 | { 18 | files: ['**/*.ts'], 19 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 20 | extends: [ 21 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 22 | ], 23 | rules: { 24 | 'no-dupe-class-members': 'off', 25 | '@typescript-eslint/no-dupe-class-members': ['error'], 26 | }, 27 | }, 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.ts eol=lf 2 | *.tsx eol=lf 3 | *.js eol=lf 4 | *.jsx eol=lf 5 | *.json eol=lf 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - '**' 5 | tags-ignore: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | pull_request: 8 | branches: 9 | - master 10 | name: build 11 | jobs: 12 | build: 13 | if: "!contains(github.event.head_commit.message, '[ci skip]')" 14 | strategy: 15 | matrix: 16 | node: [16, 18] 17 | platform: [ubuntu-latest, macos-latest, windows-latest] 18 | runs-on: ${{ matrix.platform }} 19 | steps: 20 | - name: setup node 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node }} 24 | - name: checkout 25 | uses: actions/checkout@v3 26 | - name: Get npm cache directory 27 | id: npm-cache 28 | run: | 29 | echo "::set-output name=dir::$(npm config get cache)" 30 | - uses: actions/cache@v3 31 | with: 32 | path: ${{ steps.npm-cache.outputs.dir }} 33 | key: ${{ matrix.platform }}-node-${{ matrix.node }}-${{ hashFiles('package-lock.json') }} 34 | restore-keys: | 35 | ${{ matrix.platform }}-node-${{ matrix.node }} 36 | - name: install deps 37 | run: npm ci 38 | - name: run lint 39 | run: npm run lint 40 | - name: run tests 41 | run: npm run test 42 | if: matrix.platform != 'ubuntu-latest' # Do not run this on ubuntu, more at: https://github.com/facebook/jest/issues/11438 43 | - name: build library for e2e tests 44 | run: npm run build 45 | - name: build integration tests 46 | working-directory: ./e2e/server/testdata 47 | run: npx tsc 48 | - name: run e2e tests for plugin integration 49 | run: npm run test:e2e 50 | - name: remove symlinks breaking cache 51 | run: npx rimraf ./e2e/server/testdata/node_modules 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v[0-9]+.[0-9]+.[0-9]+' 5 | name: stucco-js release 6 | jobs: 7 | build: 8 | strategy: 9 | matrix: 10 | node: [18] 11 | platform: [ubuntu-latest, macos-latest, windows-latest] 12 | runs-on: ${{ matrix.platform }} 13 | steps: 14 | - name: setup node 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: ${{ matrix.node }} 18 | - name: checkout 19 | uses: actions/checkout@v3 20 | - name: Get npm cache directory 21 | id: npm-cache 22 | run: | 23 | echo "::set-output name=dir::$(npm config get cache)" 24 | - uses: actions/cache@v3 25 | with: 26 | path: ${{ steps.npm-cache.outputs.dir }} 27 | key: ${{ matrix.platform }}-node-${{ matrix.node }}-${{ hashFiles('package-lock.json') }} 28 | restore-keys: | 29 | ${{ matrix.platform }}-node-${{ matrix.node }} 30 | - name: install deps 31 | run: npm ci 32 | - name: run lint 33 | run: npm run lint 34 | - name: run tests 35 | run: npm run test 36 | if: matrix.platform != 'ubuntu-latest' # Do not run this on ubuntu, more at: https://github.com/facebook/jest/issues/11438 37 | - name: build library for e2e tests 38 | run: npm run build 39 | - name: build integration tests 40 | working-directory: ./e2e/server/testdata 41 | run: npx tsc 42 | - name: run e2e tests for plugin integration 43 | run: npm run test:e2e 44 | - name: remove symlinks breaking cache 45 | run: npx rimraf ./e2e/server/testdata/node_modules 46 | 47 | release: 48 | needs: build 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: setup node 52 | uses: actions/setup-node@v3 53 | with: 54 | node-version: 16 55 | registry-url: 'https://registry.npmjs.org' 56 | - name: checkout 57 | uses: actions/checkout@v3 58 | - name: Get npm cache directory 59 | id: npm-cache 60 | run: | 61 | echo "::set-output name=dir::$(npm config get cache)" 62 | - uses: actions/cache@v3 63 | with: 64 | path: ${{ steps.npm-cache.outputs.dir }} 65 | key: ${{ matrix.platform }}-node-${{ matrix.node }}-${{ hashFiles('package-lock.json') }} 66 | restore-keys: | 67 | ${{ matrix.platform }}-node-${{ matrix.node }} 68 | - name: install deps 69 | run: npm install 70 | - name: run build 71 | run: npm run build 72 | - name: run publish 73 | run: npm publish 74 | env: 75 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | lib 4 | vendor 5 | coverage 6 | .vscode 7 | /e2e/server/testdata/*.js 8 | /e2e/server/testdata/*.js.map 9 | /e2e/server/testdata/*.d.ts 10 | !/e2e/server/testdata/stucco-js.js 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | tsconfig.json 4 | gulpfile.js 5 | build 6 | /proto 7 | .github 8 | /tests 9 | /coverage 10 | jest.config.js 11 | tslint.json 12 | /e2e 13 | .eslint* 14 | .prettier* 15 | .gitattributes 16 | /scripts 17 | stucco.jpg 18 | jest* 19 | .vscode 20 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: "always", 3 | printWidth: 80, 4 | semi: true, 5 | trailingComma: "all", 6 | singleQuote: true, 7 | printWidth: 120, 8 | tabWidth: 2 9 | }; 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 GraphQL Editor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://user-images.githubusercontent.com/779748/217842393-67c3d172-8039-490d-b315-e208a68f89ae.png) 2 | 3 | # Javascript runtime for Stucco 4 | ![Build](https://github.com/graphql-editor/stucco-js/workflows/build/badge.svg) 5 | 6 | - [Javascript runtime for Stucco](#javascript-runtime-for-stucco) 7 | - [About](#about) 8 | - [Configuration file](#configuration-file) 9 | - [Resolvers](#resolvers) 10 | - [Custom Scalars](#custom-scalars) 11 | - [Handler](#handler) 12 | - [Default export](#default-export) 13 | - [Handler export](#handler-export) 14 | - [Named export](#named-export) 15 | - [Passing arguments to another resolver](#passing-arguments-to-another-resolver) 16 | - [Resolver "Query.todoOperations"](#resolver-%22querytodooperations%22) 17 | - [Example](#example) 18 | - [Basic](#basic) 19 | - [Using TypeScript](#using-typescript) 20 | - [Local development](#local-development) 21 | 22 | # About 23 | 24 | Stucco-js is JavaScript/TypeScript runtime for Stucco. It can be used as a local development environment or as a base library for implementing FaaS runtime. 25 | 26 | ## Configuration file 27 | 28 | `Stucco-js` relies on [Stucco](https://github.com/graphql-editor/stucco) library written in GoLang. Configuration file format is in JSON. 29 | 30 | ### Resolvers 31 | 32 | ```json 33 | { 34 | "resolvers":{ 35 | "RESOLVER_TYPE.RESOLVER_FIELD":{ 36 | "resolve":{ 37 | "name": "PATH_TO_RESOLVER" 38 | } 39 | } 40 | } 41 | } 42 | ``` 43 | 44 | ### Custom Scalars 45 | 46 | ```json 47 | { 48 | "scalars":{ 49 | "CUSTOM_SCALAR_NAME":{ 50 | "parse":{ 51 | "name": "PATH_TO_RESOLVER" 52 | }, 53 | "serialize":{ 54 | "name": "PATH_TO_RESOLVER" 55 | } 56 | } 57 | } 58 | } 59 | ``` 60 | 61 | ## Handler 62 | 63 | You can also return Promise from handler 64 | 65 | ### Default export 66 | 67 | ```js 68 | module.exports = (input) => { 69 | return "Hello world" 70 | } 71 | ``` 72 | 73 | ### Handler export 74 | 75 | ```js 76 | module.exports.handler = (input) => { 77 | return "Hello world" 78 | } 79 | ``` 80 | 81 | ### Named export 82 | 83 | ```js 84 | module.exports.someName = (input) => { 85 | return "Hello world" 86 | } 87 | ``` 88 | 89 | ```json 90 | { 91 | "resolvers":{ 92 | "RESOLVER_TYPE.RESOLVER_FIELD":{ 93 | "resolve":{ 94 | "name": "PATH_TO_RESOLVER.someName" 95 | } 96 | } 97 | } 98 | } 99 | ``` 100 | 101 | ### Passing arguments to another resolver 102 | 103 | #### Resolver "Query.todoOperations" 104 | 105 | ```graphql 106 | type TodoOperations{ 107 | getCreditCardNumber(id: String!): String 108 | showMeTehMoney: Int 109 | } 110 | 111 | type Query{ 112 | todoOps: TodoOperations 113 | } 114 | ``` 115 | 116 | ```json 117 | { 118 | "resolvers":{ 119 | "Query.todoOps":{ 120 | "resolve":{ 121 | "name": "lib/todoOps" 122 | } 123 | }, 124 | "TopoOps.getCreditCardNumber":{ 125 | "resolve":{ 126 | "name": "lib/getCreditCardNumber" 127 | } 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | `lib/todoOps.js` 134 | ```js 135 | module.exports = (input) => { 136 | return { 137 | response:{ 138 | creditCards:{ 139 | dupa: "1234-1234-1234-1234", 140 | ddd: "1222-3332-3323-1233" 141 | } 142 | } 143 | } 144 | } 145 | ``` 146 | 147 | `lib/getCreditCardNumber.js` 148 | ```js 149 | module.exports = (input) => { 150 | const { id } = input.arguments 151 | return { 152 | response: input.source.creditCards[id] 153 | } 154 | } 155 | ``` 156 | 157 | ## Example 158 | 159 | #### Basic 160 | 161 | `schema.graphql` 162 | ```graphql 163 | type Query{ 164 | hello: String 165 | } 166 | schema{ 167 | query: Query 168 | } 169 | ``` 170 | 171 | `stucco.json` 172 | ```json 173 | { 174 | "resolvers":{ 175 | "Query.hello":{ 176 | "resolve":{ 177 | "name": "lib/hello" 178 | } 179 | } 180 | } 181 | } 182 | ``` 183 | 184 | `lib/hello.js` 185 | ```js 186 | export function handler(input){ 187 | return "Hello world" 188 | } 189 | ``` 190 | 191 | `Test query` 192 | ```gql 193 | { 194 | hello 195 | } 196 | ``` 197 | ```json 198 | { 199 | "hello": "Hello world" 200 | } 201 | ``` 202 | 203 | This JSON defines resolver 204 | 205 | #### Using TypeScript 206 | 207 | So if you have your TypeScript files in src folder you should transpile them to the lib folder and stucco can run it from there. 208 | 209 | ## Local development 210 | 211 | To start local development you need `stucco.json`, `schema.graphql`, file with resolvers in the root folder and inside root folder. To fetch your schema from URL you can use tool like [graphql-zeus](https://github.com/graphql-editor/graphql-zeus) 212 | 213 | Add this script to your package json to test your backend 214 | ```json 215 | { 216 | "scripts":{ 217 | "start": "stucco" 218 | } 219 | } 220 | ``` 221 | 222 | Or run with npx 223 | ```sh 224 | npx stucco 225 | ``` 226 | -------------------------------------------------------------------------------- /e2e/server/server.test.ts: -------------------------------------------------------------------------------- 1 | import { spawn, ChildProcess } from 'child_process'; 2 | import fetch from 'node-fetch'; 3 | import { join, delimiter } from 'path'; 4 | 5 | const node = process.platform === 'win32' ? 'node.exe' : 'node'; 6 | const pathKey = process.platform === 'win32' ? 'Path' : 'PATH'; 7 | 8 | const retry = (fn: () => Promise, retries: number, timeout: number): Promise => 9 | retries > 1 10 | ? fn().catch( 11 | () => 12 | new Promise((resolve, reject) => 13 | setTimeout( 14 | () => 15 | retry(fn, retries - 1, timeout) 16 | .then((r: T) => resolve(r)) 17 | .catch((e: unknown) => reject(e)), 18 | timeout, 19 | ), 20 | ), 21 | ) 22 | : fn(); 23 | 24 | const waitKill = async (proc?: ChildProcess) => 25 | proc && 26 | new Promise((resolve) => { 27 | proc.once('exit', resolve); 28 | proc.kill(); 29 | }); 30 | 31 | const ver = parseInt(process.versions.node.split('.')[0]); 32 | 33 | const waitSpawn = async (proc: ChildProcess) => 34 | ver >= 14 && 35 | new Promise((resolve, reject) => { 36 | proc.once('error', reject); 37 | proc.once('spawn', () => { 38 | proc.off('error', reject); 39 | resolve(); 40 | }); 41 | }); 42 | 43 | const query = (body: Record, signal?: AbortSignal) => 44 | fetch('http://localhost:8080/graphql', { 45 | body: JSON.stringify(body), 46 | method: 'POST', 47 | headers: { 48 | 'content-type': 'application/json', 49 | }, 50 | signal, 51 | }).then((res) => (res.status === 200 ? res.json() : Promise.reject('not 200'))); 52 | 53 | const ping = async () => { 54 | const controller = new AbortController(); 55 | const id = setTimeout(() => controller.abort(), 1000); 56 | const signal = controller.signal; 57 | const resp = await query({ query: '{ hero(name: "Batman") { name sidekick { name } } }' }, signal); 58 | clearTimeout(id); 59 | return resp; 60 | }; 61 | 62 | const waitPing = () => retry(ping, 5, 2000); 63 | 64 | describe('test plugin integration', () => { 65 | let stuccoProccess: ChildProcess | undefined; 66 | beforeAll(async () => { 67 | // Use run.js directly to make sure process is terminated on windows 68 | const cwd = join(process.cwd(), 'e2e', 'server', 'testdata'); 69 | const env = { ...process.env }; 70 | const p = (env[pathKey] && delimiter + env[pathKey]) || ''; 71 | env[pathKey] = `${cwd}${p.length > 1 ? p : ''}`; 72 | stuccoProccess = spawn(node, [join('..', '..', '..', 'lib', 'stucco', 'cmd.js'), 'local', 'start', '-v', '5'], { 73 | cwd, 74 | env, 75 | stdio: 'inherit', 76 | }); 77 | const proc = stuccoProccess; 78 | await waitSpawn(proc); 79 | await waitPing(); 80 | }, 30000); 81 | afterAll(async () => { 82 | const proc = stuccoProccess; 83 | stuccoProccess = undefined; 84 | await waitKill(proc); 85 | }, 30000); 86 | it('returns hero', async () => { 87 | await expect( 88 | fetch('http://localhost:8080/graphql', { 89 | body: JSON.stringify({ 90 | query: '{ hero(name: "Batman") { name sidekick { name } } }', 91 | }), 92 | method: 'POST', 93 | headers: { 94 | 'content-type': 'application/json', 95 | }, 96 | }).then((res) => res.json()), 97 | ).resolves.toEqual({ 98 | data: { 99 | hero: { 100 | name: 'Batman', 101 | sidekick: { 102 | name: 'Robin', 103 | }, 104 | }, 105 | }, 106 | }); 107 | }); 108 | it('returns sidekick', async () => { 109 | await expect( 110 | fetch('http://localhost:8080/graphql', { 111 | body: JSON.stringify({ 112 | query: '{ sidekick(name: "Robin") { name hero { name } } }', 113 | }), 114 | method: 'POST', 115 | headers: { 116 | 'content-type': 'application/json', 117 | }, 118 | }).then((res) => res.json()), 119 | ).resolves.toEqual({ 120 | data: { 121 | sidekick: { 122 | name: 'Robin', 123 | hero: { 124 | name: 'Batman', 125 | }, 126 | }, 127 | }, 128 | }); 129 | }); 130 | it('finds hero', async () => { 131 | await expect( 132 | fetch('http://localhost:8080/graphql', { 133 | body: JSON.stringify({ 134 | query: '{ search(name: "Batman") { ... on Hero { name } } }', 135 | }), 136 | method: 'POST', 137 | headers: { 138 | 'content-type': 'application/json', 139 | }, 140 | }).then((res) => res.json()), 141 | ).resolves.toEqual({ 142 | data: { 143 | search: { 144 | name: 'Batman', 145 | }, 146 | }, 147 | }); 148 | }); 149 | it('finds sidekick', async () => { 150 | await expect( 151 | fetch('http://localhost:8080/graphql', { 152 | body: JSON.stringify({ 153 | query: '{ search(name: "Robin") { ... on Sidekick { name } } }', 154 | }), 155 | method: 'POST', 156 | headers: { 157 | 'content-type': 'application/json', 158 | }, 159 | }).then((res) => res.json()), 160 | ).resolves.toEqual({ 161 | data: { 162 | search: { 163 | name: 'Robin', 164 | }, 165 | }, 166 | }); 167 | }); 168 | it('finds vilian', async () => { 169 | await expect( 170 | fetch('http://localhost:8080/graphql', { 171 | body: JSON.stringify({ 172 | query: '{ search(name: "Joker") { ... on Vilian { name } } }', 173 | }), 174 | method: 'POST', 175 | headers: { 176 | 'content-type': 'application/json', 177 | }, 178 | }).then((res) => res.json()), 179 | ).resolves.toEqual({ 180 | data: { 181 | search: { 182 | name: 'Joker', 183 | }, 184 | }, 185 | }); 186 | }); 187 | it('battles', async () => { 188 | await expect( 189 | fetch('http://localhost:8080/graphql', { 190 | body: JSON.stringify({ 191 | query: 192 | '{ battles { participants { members { name ... on Hero { sidekick { name } } ... on Sidekick { hero { name } } } } when } }', 193 | }), 194 | method: 'POST', 195 | headers: { 196 | 'content-type': 'application/json', 197 | }, 198 | }).then((res) => res.json()), 199 | ).resolves.toEqual({ 200 | data: { 201 | battles: [ 202 | { 203 | participants: [ 204 | { 205 | members: [ 206 | { 207 | name: 'Batman', 208 | sidekick: { 209 | name: 'Robin', 210 | }, 211 | }, 212 | { 213 | name: 'Robin', 214 | hero: { 215 | name: 'Batman', 216 | }, 217 | }, 218 | ], 219 | }, 220 | { 221 | members: [ 222 | { 223 | name: 'Joker', 224 | }, 225 | ], 226 | }, 227 | ], 228 | when: 'serialized date: ' + new Date(2020, 1, 1, 0, 1, 0, 0).toUTCString(), 229 | }, 230 | ], 231 | }, 232 | }); 233 | }); 234 | it('findBattles', async () => { 235 | await expect( 236 | fetch('http://localhost:8080/graphql', { 237 | body: JSON.stringify({ 238 | query: 239 | '{ findBattles(when: "' + 240 | new Date(2020, 1, 1, 0, 1, 0, 0).toUTCString() + 241 | '") { participants { members { name ... on Hero { sidekick { name } } ... on Sidekick { hero { name } } } } when } }', 242 | }), 243 | method: 'POST', 244 | headers: { 245 | 'content-type': 'application/json', 246 | }, 247 | }).then((res) => res.json()), 248 | ).resolves.toEqual({ 249 | data: { 250 | findBattles: [ 251 | { 252 | participants: [ 253 | { 254 | members: [ 255 | { 256 | name: 'Batman', 257 | sidekick: { 258 | name: 'Robin', 259 | }, 260 | }, 261 | { 262 | name: 'Robin', 263 | hero: { 264 | name: 'Batman', 265 | }, 266 | }, 267 | ], 268 | }, 269 | { 270 | members: [ 271 | { 272 | name: 'Joker', 273 | }, 274 | ], 275 | }, 276 | ], 277 | when: 'serialized date: ' + new Date(2020, 1, 1, 0, 1, 0, 0).toUTCString(), 278 | }, 279 | ], 280 | }, 281 | }); 282 | }); 283 | it('search batman', async () => { 284 | await expect( 285 | fetch('http://localhost:8080/graphql', { 286 | body: JSON.stringify({ 287 | query: 288 | '{ search(name: "Batman") { ... on Hero { name sidekick { name } } ... on Sidekick { name hero { name } } } }', 289 | }), 290 | method: 'POST', 291 | headers: { 292 | 'content-type': 'application/json', 293 | }, 294 | }).then((res) => res.json()), 295 | ).resolves.toEqual({ 296 | data: { 297 | search: { 298 | name: 'Batman', 299 | sidekick: { 300 | name: 'Robin', 301 | }, 302 | }, 303 | }, 304 | }); 305 | }); 306 | it('search robin', async () => { 307 | await expect( 308 | fetch('http://localhost:8080/graphql', { 309 | body: JSON.stringify({ 310 | query: 311 | '{ search(name: "Robin") { ... on Hero { name sidekick { name } } ... on Sidekick { name hero { name } } } }', 312 | }), 313 | method: 'POST', 314 | headers: { 315 | 'content-type': 'application/json', 316 | }, 317 | }).then((res) => res.json()), 318 | ).resolves.toEqual({ 319 | data: { 320 | search: { 321 | name: 'Robin', 322 | hero: { 323 | name: 'Batman', 324 | }, 325 | }, 326 | }, 327 | }); 328 | }); 329 | it('check if secret set', async () => { 330 | await expect( 331 | fetch('http://localhost:8080/graphql', { 332 | body: JSON.stringify({ 333 | query: '{ getSecretKey }', 334 | }), 335 | method: 'POST', 336 | headers: { 337 | 'content-type': 'application/json', 338 | }, 339 | }).then((res) => res.json()), 340 | ).resolves.toEqual({ 341 | data: { 342 | getSecretKey: 'VALUE', 343 | }, 344 | }); 345 | }); 346 | }); 347 | -------------------------------------------------------------------------------- /e2e/server/testdata/battles.ts: -------------------------------------------------------------------------------- 1 | import { FieldResolveInput, FieldResolveOutput } from '../../../lib/api'; 2 | const battles = [ 3 | { 4 | when: new Date(2020, 1, 1, 0, 1, 0, 0).toUTCString(), 5 | participants: [ 6 | { 7 | members: [ 8 | { 9 | __typename: 'Hero', 10 | name: 'Batman', 11 | sidekick: 'Robin', 12 | }, 13 | { 14 | __typename: 'Sidekick', 15 | name: 'Robin', 16 | hero: 'Batman', 17 | }, 18 | ], 19 | }, 20 | { 21 | members: [ 22 | { 23 | __typename: 'Vilian', 24 | name: 'Joker', 25 | }, 26 | ], 27 | }, 28 | ], 29 | }, 30 | ]; 31 | 32 | export default (input: FieldResolveInput): FieldResolveOutput => { 33 | const when = input.arguments && input.arguments.when; 34 | if (typeof when === 'string') { 35 | return battles.filter((battle) => battle.when === when.substr('parsed date: '.length)); 36 | } 37 | return battles; 38 | }; 39 | -------------------------------------------------------------------------------- /e2e/server/testdata/date_parse.ts: -------------------------------------------------------------------------------- 1 | import { ScalarParseInput, ScalarParseOutput } from '../../../lib/api'; 2 | 3 | export default (input: ScalarParseInput): ScalarParseOutput => { 4 | return 'parsed date: ' + input.value; 5 | }; 6 | -------------------------------------------------------------------------------- /e2e/server/testdata/date_serialize.ts: -------------------------------------------------------------------------------- 1 | import { ScalarSerializeInput, ScalarSerializeOutput } from '../../../lib/api'; 2 | 3 | export default (input: ScalarSerializeInput): ScalarSerializeOutput => { 4 | return 'serialized date: ' + input.value; 5 | }; 6 | -------------------------------------------------------------------------------- /e2e/server/testdata/get_secret_key.ts: -------------------------------------------------------------------------------- 1 | import { FieldResolveOutput } from '../../../lib/api'; 2 | 3 | export default (): FieldResolveOutput => { 4 | return process.env.KEY; 5 | }; 6 | -------------------------------------------------------------------------------- /e2e/server/testdata/hero.ts: -------------------------------------------------------------------------------- 1 | import { FieldResolveInput, FieldResolveOutput } from '../../../lib/api'; 2 | 3 | const heroes = [ 4 | { 5 | __typename: 'Hero', 6 | name: 'Batman', 7 | sidekick: 'Robin', 8 | }, 9 | ]; 10 | 11 | export default (input: FieldResolveInput): FieldResolveOutput => { 12 | const name = input.arguments?.name || (input.source as Record | undefined)?.hero; 13 | if (name) { 14 | return heroes.find((v) => v.name === name); 15 | } 16 | return null; 17 | }; 18 | -------------------------------------------------------------------------------- /e2e/server/testdata/named_interface.ts: -------------------------------------------------------------------------------- 1 | import { InterfaceResolveTypeInput, InterfaceResolveTypeOutput } from '../../../lib/api'; 2 | 3 | export default (input: InterfaceResolveTypeInput): InterfaceResolveTypeOutput => { 4 | const tp = (input.value as Record | undefined)?.__typename; 5 | if (typeof tp !== 'string') { 6 | throw new Error(`${tp} is not a valid type name`); 7 | } 8 | return tp; 9 | }; 10 | -------------------------------------------------------------------------------- /e2e/server/testdata/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar Date 2 | 3 | interface Named { 4 | name: String! 5 | } 6 | 7 | type Hero implements Named { 8 | name: String! 9 | sidekick: Sidekick 10 | } 11 | 12 | type Sidekick implements Named { 13 | name: String! 14 | hero: Hero 15 | } 16 | 17 | type Vilian implements Named { 18 | name: String! 19 | } 20 | 21 | union Search = Hero | Sidekick | Vilian 22 | 23 | type Team { 24 | members: [Named!] 25 | } 26 | 27 | type Battle { 28 | participants: [Team!] 29 | when: Date 30 | } 31 | 32 | type Query { 33 | hero(name: String!): Hero 34 | sidekick(name: String!): Sidekick 35 | search(name: String!): Search 36 | battles: [Battle!] 37 | findBattles(when: Date!): [Battle!] 38 | getSecretKey: String! 39 | } 40 | 41 | schema { 42 | query: Query 43 | } -------------------------------------------------------------------------------- /e2e/server/testdata/search.ts: -------------------------------------------------------------------------------- 1 | import { FieldResolveInput, FieldResolveOutput } from '../../../lib/api'; 2 | 3 | const search = [ 4 | { 5 | __typename: 'Hero', 6 | name: 'Batman', 7 | sidekick: 'Robin', 8 | }, 9 | { 10 | __typename: 'Sidekick', 11 | name: 'Robin', 12 | hero: 'Batman', 13 | }, 14 | { 15 | __typename: 'Vilian', 16 | name: 'Joker', 17 | }, 18 | ]; 19 | 20 | export default (input: FieldResolveInput): FieldResolveOutput => { 21 | const name = input.arguments?.name; 22 | if (name) { 23 | return search.find((v) => v.name === name); 24 | } 25 | return null; 26 | }; 27 | -------------------------------------------------------------------------------- /e2e/server/testdata/search_union.ts: -------------------------------------------------------------------------------- 1 | import { UnionResolveTypeInput, UnionResolveTypeOutput } from '../../../lib/api'; 2 | 3 | export default (input: UnionResolveTypeInput): UnionResolveTypeOutput => { 4 | const tp = (input.value as Record | undefined)?.__typename; 5 | if (typeof tp !== 'string') { 6 | throw new Error(`${tp} is not a valid type name`); 7 | } 8 | return tp; 9 | }; 10 | -------------------------------------------------------------------------------- /e2e/server/testdata/sidekick.ts: -------------------------------------------------------------------------------- 1 | import { FieldResolveInput, FieldResolveOutput } from '../../../lib/api'; 2 | 3 | const sidekicks = [ 4 | { 5 | __typename: 'Sidekick', 6 | name: 'Robin', 7 | hero: 'Batman', 8 | }, 9 | ]; 10 | 11 | export default (input: FieldResolveInput): FieldResolveOutput => { 12 | const name = input.arguments?.name || (input.source as Record | undefined)?.sidekick; 13 | if (name) { 14 | return sidekicks.find((v) => v.name === name); 15 | } 16 | return null; 17 | }; 18 | -------------------------------------------------------------------------------- /e2e/server/testdata/stucco-js.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\..\..\..\lib\cli\cli.js" %* 4 | -------------------------------------------------------------------------------- /e2e/server/testdata/stucco-js.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawn } from 'child_process'; 4 | import { join } from 'path'; 5 | const stuccoProccess = spawn('node', [join('..', '..', '..', 'lib', 'cli', 'cli.js'), ...process.argv.slice(2)], { 6 | cwd: process.cwd(), 7 | env: process.env, 8 | stdio: 'inherit', 9 | }); 10 | const kill = () => stuccoProccess.kill() 11 | process.on('SIGTERM', kill); 12 | process.on('SIGINT', kill); 13 | -------------------------------------------------------------------------------- /e2e/server/testdata/stucco.json: -------------------------------------------------------------------------------- 1 | { 2 | "resolvers": { 3 | "Hero.sidekick": { 4 | "resolve": { 5 | "name": "sidekick.js" 6 | } 7 | }, 8 | "Sidekick.hero": { 9 | "resolve": { 10 | "name": "hero.js" 11 | } 12 | }, 13 | "Query.hero": { 14 | "resolve": { 15 | "name": "hero.js" 16 | } 17 | }, 18 | "Query.sidekick": { 19 | "resolve": { 20 | "name": "sidekick.js" 21 | } 22 | }, 23 | "Query.search": { 24 | "resolve": { 25 | "name": "search.js" 26 | } 27 | }, 28 | "Query.battles": { 29 | "resolve": { 30 | "name": "battles.js" 31 | } 32 | }, 33 | "Query.findBattles": { 34 | "resolve": { 35 | "name": "battles.js" 36 | } 37 | }, 38 | "Query.getSecretKey": { 39 | "resolve": { 40 | "name": "get_secret_key.js" 41 | } 42 | } 43 | }, 44 | "interfaces": { 45 | "Named": { 46 | "resolveType": { 47 | "name": "named_interface.js" 48 | } 49 | } 50 | }, 51 | "unions": { 52 | "Search": { 53 | "resolveType": { 54 | "name": "search_union.js" 55 | } 56 | } 57 | }, 58 | "scalars": { 59 | "Date": { 60 | "parse": { 61 | "name": "date_parse.js" 62 | }, 63 | "serialize": { 64 | "name": "date_serialize.js" 65 | } 66 | } 67 | }, 68 | "secrets": { 69 | "secrets": { 70 | "KEY": "VALUE" 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /e2e/server/testdata/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "strict":true, 5 | "baseUrl": ".", 6 | "outDir": "./", 7 | "rootDir": "./", 8 | "sourceMap": true, 9 | "inlineSources": true, 10 | "declaration": true, 11 | "module": "es2020", 12 | "esModuleInterop": true, 13 | "resolveJsonModule": true, 14 | "stripInternal": true, 15 | "moduleResolution": "node", 16 | "emitDecoratorMetadata": true, 17 | "experimentalDecorators": true, 18 | "target": "es2020", 19 | "typeRoots": ["./node_modules/@types", "./src/typings"], 20 | "types": ["node", "jest"] 21 | }, 22 | "include": ["./*.ts"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const ts = require('gulp-typescript'); 3 | const tsProject = ts.createProject('tsconfig.json'); 4 | 5 | gulp.task('ts-compile', () => { 6 | const result = tsProject.src().pipe(tsProject()); 7 | 8 | return Promise.all([result.js.pipe(gulp.dest('lib')), result.dts.pipe(gulp.dest('lib'))]); 9 | }); 10 | 11 | gulp.task('copy-cmd', () => gulp.src('./src/cli/cli.cmd').pipe(gulp.dest('./lib/cli'))); 12 | 13 | gulp.task('proto', () => { 14 | return gulp.src(['src/proto/**/*.js']).pipe(gulp.dest('lib/proto')); 15 | }); 16 | 17 | gulp.task('default', gulp.parallel(['proto', 'ts-compile', 'copy-cmd'])); 18 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src', '/tests'], 3 | collectCoverageFrom: ['**/src/**/*.ts', '!**/src/proto/*.ts'], 4 | transform: { 5 | '^.+\\.ts?$': [ 6 | 'ts-jest', 7 | { 8 | useESM: true, 9 | tsconfig: 'tsconfig.json', 10 | }, 11 | ], 12 | }, 13 | testEnvironment: 'node', 14 | testMatch: ['**/tests/**/*.test.ts'], 15 | extensionsToTreatAsEsm: ['.ts'], 16 | moduleFileExtensions: ['ts', 'js'], 17 | testRunner: 'jest-circus/runner', 18 | moduleNameMapper: { 19 | '^(\\.{1,2}/.*)\\.js$': '$1', 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /jest.e2e.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src', '/e2e'], 3 | collectCoverage: false, 4 | transform: { 5 | '^.+\\.ts?$': [ 6 | 'ts-jest', 7 | { 8 | useESM: true, 9 | tsconfig: 'tsconfig.json', 10 | }, 11 | ], 12 | }, 13 | testEnvironment: 'node', 14 | testMatch: ['**/e2e/**/*.test.ts'], 15 | moduleFileExtensions: ['ts', 'js'], 16 | preset: 'ts-jest/presets/default-esm', 17 | moduleNameMapper: { 18 | '^(\\.{1,2}/.*)\\.js$': '$1', 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stucco-js", 3 | "version": "0.10.19", 4 | "main": "./lib/index.js", 5 | "types": "./lib/index.d.ts", 6 | "type": "module", 7 | "bin": { 8 | "stucco-js": "lib/cli/cli.js", 9 | "stucco-js.cmd": "lib/cli/cli.cmd", 10 | "stucco": "lib/stucco/cmd.js", 11 | "stucco.cmd": "lib/stucco/cmd.cmd" 12 | }, 13 | "scripts": { 14 | "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest -c jest.config.cjs", 15 | "test:e2e": "cross-env NODE_OPTIONS=--experimental-vm-modules jest -c jest.e2e.config.cjs", 16 | "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" \"e2e/**/*.ts\" \"*.js\"", 17 | "bump-stucco": "node scripts/bump_stucco.js", 18 | "clean": "rimraf lib/", 19 | "copyfiles": "copyfiles -u 1 src/cli/cli.cmd src/stucco/cmd.cmd lib/", 20 | "build": "npm run clean && tsc && npm run copyfiles" 21 | }, 22 | "license": "MIT", 23 | "dependencies": { 24 | "@grpc/grpc-js": "^1.3.7", 25 | "bin-version-check": "^5.0.0", 26 | "google-protobuf": "^3.18.0", 27 | "grpc-health-check-ts": "^1.0.2", 28 | "lru-cache": "^6.0.0", 29 | "node-forge": "^1.3.1", 30 | "retry": "^0.13.1", 31 | "stucco-ts-proto-gen": "^0.7.21", 32 | "uuid": "^8.3.2", 33 | "yargs": "^17.2.1" 34 | }, 35 | "devDependencies": { 36 | "@types/google-protobuf": "^3.15.5", 37 | "@types/jest": "^29.4.0", 38 | "@types/lru-cache": "^5.1.1", 39 | "@types/node": "^18.11.19", 40 | "@types/node-forge": "^1.0.4", 41 | "@types/retry": "^0.12.2", 42 | "@types/uuid": "^8.3.1", 43 | "@types/yargs": "^17.0.3", 44 | "@typescript-eslint/eslint-plugin": "^5.50.0", 45 | "@typescript-eslint/eslint-plugin-tslint": "^5.50.0", 46 | "@typescript-eslint/parser": "^5.50.0", 47 | "copyfiles": "^2.4.1", 48 | "cross-env": "^7.0.3", 49 | "eslint": "^8.33.0", 50 | "eslint-config-prettier": "^8.6.0", 51 | "eslint-plugin-import": "^2.27.5", 52 | "eslint-plugin-jest": "^27.2.1", 53 | "eslint-plugin-prefer-arrow": "^1.2.3", 54 | "eslint-plugin-prettier": "^4.2.1", 55 | "grpc_tools_node_protoc_ts": "^5.3.2", 56 | "grpc-tools": "^1.11.2", 57 | "jest": "^29.4.1", 58 | "node-abort-controller": "^3.0.0", 59 | "node-fetch": "^3.2.10", 60 | "prettier": "^2.8.3", 61 | "rimraf": "^4.1.2", 62 | "ts-jest": "^29.0.5", 63 | "typescript": "^4.9.5" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /scripts/bump_stucco.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import fs from 'fs'; 3 | fetch( 4 | "https://stucco-release.fra1.cdn.digitaloceanspaces.com/latest/version" 5 | ) 6 | .then(r => r.text()) 7 | .then(b => fs.writeFileSync("src/stucco/version.ts", `export const version = '${b.trim()}';\n`)); 8 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export interface NamedTypeRef { 2 | name: string; 3 | } 4 | 5 | export interface NonNullTypeRef { 6 | nonNull: TypeRef; 7 | } 8 | 9 | export interface ListTypeRef { 10 | list: TypeRef; 11 | } 12 | 13 | export type TypeRef = NamedTypeRef | NonNullTypeRef | ListTypeRef | undefined; 14 | 15 | export const isNamedTypeRef = (tp: TypeRef): tp is NamedTypeRef => typeof tp !== 'undefined' && 'name' in tp; 16 | 17 | export const isNonNullTypeRef = (tp: TypeRef): tp is NonNullTypeRef => typeof tp !== 'undefined' && 'nonNull' in tp; 18 | 19 | export const isListTypeRef = (tp: TypeRef): tp is ListTypeRef => typeof tp !== 'undefined' && 'list' in tp; 20 | 21 | export interface ResponsePath { 22 | prev?: ResponsePath; 23 | key: unknown; 24 | } 25 | 26 | export interface Directive { 27 | name: string; 28 | arguments?: Record; 29 | } 30 | 31 | export type Directives = Directive[]; 32 | 33 | export interface Variable { 34 | name: string; 35 | } 36 | 37 | export interface VariableDefinition { 38 | variable: Variable; 39 | defaultValue?: unknown; 40 | } 41 | 42 | export type VariableDefinitions = VariableDefinition[]; 43 | 44 | export interface FieldSelection { 45 | name: string; 46 | arguments?: Record; 47 | directives?: Directives; 48 | selectionSet?: Selections; 49 | } 50 | 51 | export interface FragmentDefitnion { 52 | typeCondition: TypeRef; 53 | directives?: Directives; 54 | variableDefinitions?: VariableDefinitions; 55 | selectionSet: Selections; 56 | } 57 | 58 | export interface FragmentSelection { 59 | definition: FragmentDefitnion; 60 | } 61 | 62 | export type Selection = FieldSelection | FragmentSelection; 63 | 64 | export type Selections = Selection[]; 65 | 66 | export interface OperationDefinition { 67 | operation: string; 68 | name?: string; 69 | variableDefinitions?: VariableDefinitions; 70 | directives?: Directives; 71 | selectionSet?: Selections; 72 | } 73 | 74 | export interface HttpRequestURL { 75 | host?: string; 76 | path?: string; 77 | query?: string; 78 | scheme?: string; 79 | } 80 | 81 | export interface HttpRequest { 82 | body?: Buffer; 83 | headers?: Record; 84 | host?: string; 85 | method?: string; 86 | proto?: string; 87 | remoteAddress?: string; 88 | url?: HttpRequestURL; 89 | } 90 | 91 | export type Protocol = HttpRequest; 92 | 93 | export interface FieldResolveInfo { 94 | fieldName: string; 95 | path?: ResponsePath; 96 | returnType?: TypeRef; 97 | parentType?: TypeRef; 98 | operation?: OperationDefinition; 99 | variableValues?: Record; 100 | rootValue?: unknown; 101 | } 102 | 103 | export interface FieldResolveInput | undefined, Source = unknown | undefined> { 104 | source?: Source; 105 | arguments?: Arguments; 106 | info: FieldResolveInfo; 107 | protocol?: Protocol; 108 | environment?: unknown; 109 | subscriptionPayload?: unknown; 110 | } 111 | 112 | export type FieldResolveOutput unknown)> = 113 | | { 114 | response?: T; 115 | error?: Error; 116 | } 117 | | T; 118 | 119 | export interface InterfaceResolveTypeInfo { 120 | fieldName: string; 121 | path?: ResponsePath; 122 | returnType?: TypeRef; 123 | parentType?: TypeRef; 124 | operation?: OperationDefinition; 125 | variableValues?: Record; 126 | } 127 | 128 | export interface InterfaceResolveTypeInput { 129 | value?: unknown; 130 | info: InterfaceResolveTypeInfo; 131 | } 132 | 133 | export type InterfaceResolveTypeOutput = 134 | | { 135 | type?: string | (() => string); 136 | error?: Error; 137 | } 138 | | (() => string) 139 | | string; 140 | 141 | export interface SetSecretsInput { 142 | secrets: { 143 | [k: string]: string; 144 | }; 145 | } 146 | 147 | export type SetSecretsOutput = 148 | | { 149 | error?: Error; 150 | } 151 | | (() => void) 152 | | undefined; 153 | 154 | export interface ScalarParseInput { 155 | value: unknown; 156 | } 157 | 158 | export type ScalarParseOutput = 159 | | { 160 | response?: unknown | (() => unknown); 161 | error?: Error; 162 | } 163 | | (() => unknown) 164 | | unknown; 165 | 166 | export interface ScalarSerializeInput { 167 | value: unknown; 168 | } 169 | 170 | export type ScalarSerializeOutput = 171 | | { 172 | response?: unknown | (() => unknown); 173 | error?: Error; 174 | } 175 | | (() => unknown) 176 | | unknown; 177 | 178 | export interface UnionResolveTypeInfo { 179 | fieldName: string; 180 | path?: ResponsePath; 181 | returnType?: TypeRef; 182 | parentType?: TypeRef; 183 | operation?: OperationDefinition; 184 | variableValues?: Record; 185 | } 186 | 187 | export interface UnionResolveTypeInput { 188 | value?: unknown; 189 | info: UnionResolveTypeInfo; 190 | } 191 | 192 | export type UnionResolveTypeOutput = 193 | | { 194 | type?: string | (() => string); 195 | error?: Error; 196 | } 197 | | (() => string) 198 | | string; 199 | 200 | export interface SubscriptionConnectionInput { 201 | query: string; 202 | variableValues?: Record; 203 | operationName?: string; 204 | protocol?: Protocol; 205 | operation?: OperationDefinition; 206 | } 207 | 208 | export type SubscriptionConnectionOutput unknown)> = 209 | | { 210 | response?: T; 211 | error?: Error; 212 | } 213 | | T; 214 | 215 | export interface SubscriptionListenEmitter { 216 | emit: (v?: unknown) => Promise; 217 | on(ev: 'close', handler: (err?: Error) => void): void; 218 | off(ev: 'close', handler: (err?: Error) => void): void; 219 | } 220 | export interface SubscriptionListenInput { 221 | query: string; 222 | variableValues?: Record; 223 | operationName?: string; 224 | protocol?: Protocol; 225 | operation?: OperationDefinition; 226 | } 227 | 228 | export interface AuthorizeInput { 229 | query?: string; 230 | variableValues?: Record; 231 | operationName?: string; 232 | protocol?: Protocol; 233 | } 234 | export type AuthorizeOutput = boolean | { error: Error }; 235 | 236 | export type FieldResolveHandler = (input: FieldResolveInput) => FieldResolveOutput; 237 | export type InterfaceResolveTypeHandler = (input: InterfaceResolveTypeInput) => InterfaceResolveTypeOutput; 238 | export type ScalarParseHandler = (input: ScalarParseInput) => ScalarParseOutput; 239 | export type ScalarSerializeHandler = (input: ScalarSerializeInput) => ScalarSerializeOutput; 240 | export type UnionResolveTypeHandler = (input: UnionResolveTypeInput) => UnionResolveTypeOutput; 241 | export type SubscriptionConnectionHandler = (input: SubscriptionConnectionInput) => SubscriptionConnectionOutput; 242 | export type SubscriptionListenHandler = (input: SubscriptionListenInput, emitter: SubscriptionListenEmitter) => void; 243 | 244 | export type AuthorizeHandler = (input: AuthorizeInput) => AuthorizeOutput; 245 | -------------------------------------------------------------------------------- /src/cli/cli.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\cli.js" %* 4 | -------------------------------------------------------------------------------- /src/cli/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import yargs from 'yargs'; 3 | import * as azure from './cmds/azure.js'; 4 | import * as config from './cmds/config.js'; 5 | import * as plugin from './cmds/plugin.js'; 6 | 7 | // Plugin compatibility 8 | let args = process.argv.slice(2); 9 | if (args.length === 0) { 10 | args = ['plugin', 'serve']; 11 | } 12 | yargs(args).command([azure, config, plugin]).help().argv; 13 | -------------------------------------------------------------------------------- /src/cli/cmds/azure.ts: -------------------------------------------------------------------------------- 1 | import { Argv } from 'yargs'; 2 | import * as serve from './azure_cmds/serve.js'; 3 | 4 | export const command = 'azure '; 5 | export const describe = 'Azure Function commands'; 6 | export const builder = (yargs: Argv): Argv => yargs.command([serve]); 7 | export function handler(): void { 8 | return; 9 | } 10 | -------------------------------------------------------------------------------- /src/cli/cmds/azure_cmds/serve.ts: -------------------------------------------------------------------------------- 1 | import { createServer, IncomingMessage, ServerResponse } from 'http'; 2 | import { Socket } from 'net'; 3 | import { Readable } from 'stream'; 4 | import { handleHTTPGrpc, UserError } from '../../../http/handle.js'; 5 | import { ClientCertAuth, createPEM } from '../../../security/index.js'; 6 | import { readFile } from 'fs'; 7 | 8 | export const command = 'serve'; 9 | export const describe = 'Serve Azure custom handler'; 10 | export const builder = {}; 11 | 12 | async function readBody(req: Readable): Promise { 13 | return new Promise((resolve, reject) => { 14 | const data: Buffer[] = []; 15 | req.on('readable', () => { 16 | const chunk = req.read(); 17 | if (chunk) { 18 | data.push(Buffer.from(chunk)); 19 | } 20 | }); 21 | req.on('error', (err) => reject(err)); 22 | req.on('end', () => resolve(Buffer.concat(data))); 23 | }); 24 | } 25 | 26 | interface Auth { 27 | authorize(req: IncomingMessage): Promise; 28 | } 29 | 30 | type HandlerFn = (req: IncomingMessage, res: ServerResponse) => Promise; 31 | function customHandler(sec: Auth): HandlerFn { 32 | return async (req: IncomingMessage, res: ServerResponse): Promise => { 33 | try { 34 | const authorized = await sec.authorize(req); 35 | if (!authorized) { 36 | const body = 'FORBIDDEN'; 37 | res 38 | .writeHead(403, { 39 | 'Content-Length': body.length, 40 | 'Content-Type': 'text/plain', 41 | }) 42 | .end(body); 43 | return; 44 | } 45 | if (req?.method !== 'POST') { 46 | throw new UserError('only POST accepted'); 47 | } 48 | const rContentType = (req?.headers || {})['content-type'] || ''; 49 | if (!rContentType) { 50 | throw new UserError('invalid content type'); 51 | } 52 | const [contentType, body] = await handleHTTPGrpc(rContentType, await readBody(req)); 53 | res 54 | .writeHead(200, { 55 | 'Content-Length': body.length, 56 | 'Content-Type': contentType, 57 | }) 58 | .end(Buffer.from(body)); 59 | } catch (e) { 60 | const errResp = { 61 | status: e instanceof UserError ? 400 : 500, 62 | body: Buffer.from(e instanceof Error ? e.message : ''), 63 | }; 64 | res 65 | .writeHead(errResp.status, { 66 | 'Content-Length': errResp.body.length, 67 | 'Content-Type': 'text/plain', 68 | }) 69 | .end(errResp.body); 70 | } 71 | }; 72 | } 73 | 74 | class AzureCertReader { 75 | async ReadCert(req: IncomingMessage): Promise { 76 | const getAsString = (v: string | string[] | undefined): string | undefined => (Array.isArray(v) ? v.join(', ') : v); 77 | const header = getAsString(req.headers['x-arr-clientcert']); 78 | if (!header) throw new Error('missing certificate'); 79 | return createPEM(header); 80 | } 81 | } 82 | 83 | class AzureCaReader { 84 | constructor(private caFile = './ca.pem') {} 85 | async ReadCa(): Promise { 86 | return new Promise((resolve, reject) => 87 | readFile(this.caFile, (err, data) => (err ? reject(err) : resolve(data.toString()))), 88 | ); 89 | } 90 | } 91 | 92 | class AzureCertAuth extends ClientCertAuth { 93 | constructor() { 94 | super(new AzureCertReader(), { ca: new AzureCaReader() }); 95 | } 96 | async authorize(req: IncomingMessage): Promise { 97 | return process.env['AZURE_FUNCTIONS_ENVIRONMENT'] === 'Development' || super.authorize(req); 98 | } 99 | } 100 | 101 | export function handler(): void { 102 | const srv = createServer(customHandler(new AzureCertAuth())); 103 | let port = parseInt(process.env['FUNCTIONS_CUSTOMHANDLER_PORT'] || ''); 104 | if (isNaN(port) || !port) { 105 | port = 8080; 106 | } 107 | srv.listen(port, () => { 108 | console.log('custom azure handler is running'); 109 | }); 110 | const sockets: Socket[] = []; 111 | srv.on('connection', (socket) => { 112 | sockets.push(socket); 113 | const removeSocket = () => { 114 | const at = sockets.indexOf(socket); 115 | if (at !== -1) { 116 | sockets.splice(at, 1); 117 | } 118 | }; 119 | socket.on('close', removeSocket); 120 | }); 121 | process.on('SIGTERM', () => { 122 | const wait = setTimeout(() => { 123 | sockets.forEach((s) => s.destroy()); 124 | }, 10 * 1000); 125 | srv.close((err) => { 126 | if (err) { 127 | console.error(err); 128 | } 129 | clearTimeout(wait); 130 | }); 131 | }); 132 | } 133 | -------------------------------------------------------------------------------- /src/cli/cmds/config.ts: -------------------------------------------------------------------------------- 1 | export { command, describe, builder, handler } from './plugin_cmds/config.js'; 2 | -------------------------------------------------------------------------------- /src/cli/cmds/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Argv } from 'yargs'; 2 | import * as config from './plugin_cmds/config.js'; 3 | import * as serve from './plugin_cmds/serve.js'; 4 | 5 | export const command = 'plugin '; 6 | export const describe = 'Plugin server commands'; 7 | export const builder = (yargs: Argv): Argv => yargs.command([config, serve]); 8 | export function handler(): void { 9 | return; 10 | } 11 | -------------------------------------------------------------------------------- /src/cli/cmds/plugin_cmds/config.ts: -------------------------------------------------------------------------------- 1 | export const command = 'config'; 2 | export const describe = 'Return plugin config'; 3 | export const builder = {}; 4 | export function handler(): void { 5 | const opts: { version?: string } = {}; 6 | const { version = process.version } = opts; 7 | process.stdout.write( 8 | JSON.stringify([ 9 | { 10 | provider: 'local', 11 | runtime: 'nodejs', 12 | }, 13 | { 14 | provider: 'local', 15 | runtime: 'nodejs-' + version.slice(1, version.indexOf('.')), 16 | }, 17 | ]), 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/cli/cmds/plugin_cmds/serve.ts: -------------------------------------------------------------------------------- 1 | import { Argv, Arguments } from 'yargs'; 2 | import { run } from '../../../server/index.js'; 3 | export const command = 'serve'; 4 | export const describe = 'Run plugin stucco server'; 5 | 6 | const sizes = ['KB', 'MB', 'GB', 'TB']; 7 | function toBytesSize(v: unknown): number { 8 | if (typeof v === 'number') { 9 | return v; 10 | } 11 | if (!v || typeof v !== 'string') { 12 | return 0; 13 | } 14 | let uv = v.toUpperCase(); 15 | const size = sizes.findIndex((s) => uv.endsWith(s)) + 1; 16 | if (size && size <= sizes.length) { 17 | uv = uv.substr(0, uv.length - 2).trim(); 18 | } 19 | const nv = parseInt(uv); 20 | if (isNaN(nv)) { 21 | throw new Error(`${v} is not a valid size string`); 22 | } 23 | return nv * Math.pow(2, 10 * size); 24 | } 25 | export const builder = (yargs: Argv): Argv => 26 | yargs 27 | .option('enable-profiling', { 28 | default: process.env.STUCCO_JS_PROFILE === '1' || process.env.STUCCO_JS_PROFILE === 'true', 29 | desc: 'Enables some simple profiling for plugin', 30 | }) 31 | .option('max-message-size', { 32 | default: process.env.STUCCO_MAX_MESSAGE_SIZE || '1mb', 33 | desc: 'Max size of message in bytes. Accepts human readable strings. (for ex: 1024, 1kb, 1KB, 1mb, 1MB etc)', 34 | }); 35 | export function handler(args: Arguments): void { 36 | const maxMessageSize = toBytesSize(args.maxMessageSize); 37 | const enableProfiling = args.enableProfiling === true; 38 | const done = (e?: unknown) => { 39 | if (e) console.error(e); 40 | process.exit(e ? 1 : 0); 41 | }; 42 | process.on('SIGTERM', done); 43 | process.on('SIGINT', done); 44 | run({ maxMessageSize, enableProfiling }).catch(done); 45 | } 46 | -------------------------------------------------------------------------------- /src/cli/util.ts: -------------------------------------------------------------------------------- 1 | export const extensions = [__filename.split('.')[1]]; 2 | -------------------------------------------------------------------------------- /src/handler/index.ts: -------------------------------------------------------------------------------- 1 | import { extname, resolve, normalize, join } from 'path'; 2 | import { promises } from 'fs'; 3 | interface WithName { 4 | getName: () => string; 5 | } 6 | export interface WithFunction { 7 | hasFunction(): boolean; 8 | getFunction(): WithName | undefined; 9 | } 10 | 11 | function handlerFunc(name: string, mod: { [k: string]: unknown }): (x: T, y?: V) => Promise | U { 12 | let path = name.split('.').filter((v) => v); 13 | let v = path.reduce( 14 | (pv: Record | undefined, cv: string) => pv && (pv[cv] as Record | undefined), 15 | mod, 16 | ); 17 | if (!path.length || !v) { 18 | if ('default' in mod && !('handler' in mod)) { 19 | mod = mod.default as { [k: string]: unknown }; 20 | } 21 | if (typeof mod === 'function') { 22 | return mod as (x: T, y?: V) => Promise | U; 23 | } 24 | if ('handler' in mod && !path.length) { 25 | path = ['handler']; 26 | } 27 | v = path.reduce( 28 | (pv: Record | undefined, cv: string) => pv && (pv[cv] as Record | undefined), 29 | mod, 30 | ); 31 | } 32 | if (v && 'handler' in v && typeof v.handler === 'function') { 33 | return v.handler as (x: T, y?: V) => Promise | U; 34 | } 35 | if (!path.length || typeof v !== 'function') { 36 | throw new TypeError('invalid handler module'); 37 | } 38 | return v as (x: T, y?: V) => Promise | U; 39 | } 40 | 41 | const cache: { 42 | [k: string]: (arg1: unknown, arg2?: unknown) => unknown; 43 | } = {}; 44 | 45 | function cachedFunc(fnName: string): ((x: T, y?: V) => Promise) | undefined { 46 | const cached = cache[fnName]; 47 | if (cached) { 48 | return cached as (x: T, y?: V) => Promise; 49 | } 50 | return; 51 | } 52 | 53 | async function findExtFrom(fnName: string, ext: string[]): Promise { 54 | if (!ext.length) throw new Error('extension not found'); 55 | return promises 56 | .stat(fnName + ext[0]) 57 | .then(() => ext[0]) 58 | .catch(() => findExtFrom(fnName, ext.slice(1))); 59 | } 60 | async function findExt(fnName: string): Promise { 61 | fnName = new URL(fnName).pathname; 62 | if (process.platform === 'win32' && fnName.match(/^\/[a-zA-Z]:\/.*$/)) { 63 | fnName = fnName.slice(1); 64 | } 65 | return findExtFrom(fnName, ['.js', '.cjs', '.mjs']); 66 | } 67 | 68 | const splitRefWithImport = (v: string) => 69 | v.split('@').reduce((pv, cv) => (pv[0] ? [pv[0], pv[1] ? [pv[1], cv].join('@') : cv] : [cv, '']), ['', '']); 70 | 71 | async function loadLocal(fnName: string): Promise<(arg1: T, arg2?: V) => Promise> { 72 | const [importName, name] = splitRefWithImport(fnName); 73 | fnName = `file:///${resolve(importName)}`; 74 | const baseExt = extname(fnName); 75 | const fName = baseExt === '' ? await findExt(fnName) : baseExt; 76 | const ext = fName.match(/^\.[mc]?js$/) ? fName : await findExt(fnName.slice(0, -fName.length)); 77 | const importPath = (baseExt.length ? fnName.slice(0, -baseExt.length) : fnName) + ext; 78 | const mod = await import(importPath); 79 | const handler = handlerFunc(name || (fName === ext ? '' : fName.slice(1)), mod); 80 | const wrapHandler = (x: T, y?: V): Promise => Promise.resolve(handler(x, y)); 81 | cache[fnName] = wrapHandler as (arg1: unknown, arg2?: unknown) => unknown; 82 | return wrapHandler; 83 | } 84 | 85 | async function findNodeModules(target: string, at: string = process.cwd()): Promise { 86 | at = normalize(at); 87 | try { 88 | const nm = join(at, 'node_modules', target); 89 | await promises.stat(nm); 90 | return nm; 91 | } catch (e) { 92 | const up = normalize(join(at, '..')); 93 | if (up === at) { 94 | throw e; 95 | } 96 | return findNodeModules(target, up); 97 | } 98 | } 99 | 100 | async function loadModule(modName: string): Promise<(arg1: T, arg2?: V) => Promise> { 101 | const [importName, name] = splitRefWithImport(modName); 102 | modName = importName; 103 | const [importPath, fName] = modName.split('.'); 104 | await findNodeModules(importPath); 105 | const mod = await import(importPath); 106 | const handler = handlerFunc(name || (fName && fName.slice(1)) || '', mod); 107 | const wrapHandler = (x: T, y?: V): Promise => Promise.resolve(handler(x, y)); 108 | cache[modName] = wrapHandler as (arg1: unknown, arg2?: unknown) => unknown; 109 | return wrapHandler; 110 | } 111 | 112 | export async function getHandler(req: WithFunction): Promise<(x: T, y?: V) => Promise> { 113 | if (!req.hasFunction()) { 114 | throw new Error('missing function'); 115 | } 116 | const fn = req.getFunction(); 117 | if (typeof fn === 'undefined' || !fn.getName()) { 118 | throw new Error(`function name is empty`); 119 | } 120 | const fnName = fn.getName(); 121 | const cached = cachedFunc(`file:///${resolve(fnName)}`) || cachedFunc(fnName); 122 | if (cached) { 123 | return cached; 124 | } 125 | try { 126 | const handler = await loadLocal(fnName); 127 | return handler; 128 | } catch (e) { 129 | const handler = await loadModule(fnName).catch(console.error); 130 | if (!handler) { 131 | throw e; 132 | } 133 | return handler; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/http/handle.ts: -------------------------------------------------------------------------------- 1 | //import { isFunction } from 'util'; 2 | import { getMessageType, parseMIME, MessageType, messageTypeToMime } from '../raw/message.js'; 3 | import { 4 | fieldResolveHandler, 5 | interfaceResolveTypeHandler, 6 | scalarParseHandler, 7 | scalarSerializeHandler, 8 | setSecretsHandler, 9 | subscriptionConnectionHandler, 10 | } from '../raw/index.js'; 11 | 12 | export class UserError extends Error {} 13 | 14 | export async function handleHTTPGrpc(contentType: string, body: Buffer): Promise<[string, Uint8Array]> { 15 | const msgType = getMessageType(parseMIME(contentType)); 16 | let data: Uint8Array; 17 | let responseMessageType: MessageType; 18 | switch (msgType) { 19 | case MessageType.FIELD_RESOLVE_REQUEST: 20 | data = await fieldResolveHandler(contentType, Uint8Array.from(body)); 21 | responseMessageType = MessageType.FIELD_RESOLVE_RESPONSE; 22 | break; 23 | case MessageType.INTERFACE_RESOLVE_TYPE_REQUEST: 24 | data = await interfaceResolveTypeHandler(contentType, Uint8Array.from(body)); 25 | responseMessageType = MessageType.INTERFACE_RESOLVE_TYPE_RESPONSE; 26 | break; 27 | case MessageType.SCALAR_PARSE_REQUEST: 28 | data = await scalarParseHandler(contentType, Uint8Array.from(body)); 29 | responseMessageType = MessageType.SCALAR_PARSE_RESPONSE; 30 | break; 31 | case MessageType.SCALAR_SERIALIZE_REQUEST: 32 | data = await scalarSerializeHandler(contentType, Uint8Array.from(body)); 33 | responseMessageType = MessageType.SCALAR_SERIALIZE_RESPONSE; 34 | break; 35 | case MessageType.SET_SECRETS_REQUEST: 36 | data = await setSecretsHandler(contentType, Uint8Array.from(body)); 37 | responseMessageType = MessageType.SET_SECRETS_RESPONSE; 38 | break; 39 | case MessageType.SUBSCRIPTION_CONNECTION_REQUEST: 40 | data = await subscriptionConnectionHandler(contentType, Uint8Array.from(body)); 41 | responseMessageType = MessageType.SUBSCRIPTION_CONNECTION_RESPONSE; 42 | break; 43 | default: 44 | throw new UserError('invalid message type'); 45 | } 46 | return [messageTypeToMime(responseMessageType), data]; 47 | } 48 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Server, run } from './server/index.js'; 2 | export { getHandler } from './handler/index.js'; 3 | export * from './api/index.js'; 4 | -------------------------------------------------------------------------------- /src/proto/driver/directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive as APIDirective, Directives as APIDirectives } from '../../api/index.js'; 2 | import { Directive } from './messages.js'; 3 | import { RecordOfValues, getRecordFromValueMap } from './value.js'; 4 | 5 | function buildDirective(dir: Directive, variables?: RecordOfValues): APIDirective { 6 | const args = getRecordFromValueMap(dir.getArgumentsMap(), variables); 7 | return { 8 | ...(Object.keys(args).length > 0 && { arguments: args }), 9 | name: dir.getName(), 10 | }; 11 | } 12 | 13 | export const buildDirectives = (dirs: Directive[], variables?: RecordOfValues): APIDirectives | undefined => 14 | dirs.length > 0 ? dirs.map((dir) => buildDirective(dir, variables)) : undefined; 15 | -------------------------------------------------------------------------------- /src/proto/driver/driver_service.ts: -------------------------------------------------------------------------------- 1 | import { driverService } from 'stucco-ts-proto-gen'; 2 | 3 | export type IDriverService = typeof driverService.DriverService; 4 | export type IDriverService_IFieldResolve = IDriverService['fieldResolve']; 5 | export type IDriverService_IInterfaceResolveType = IDriverService['interfaceResolveType']; 6 | export type IDriverService_IScalarParse = IDriverService['scalarParse']; 7 | export type IDriverService_IScalarSerialize = IDriverService['scalarSerialize']; 8 | export type IDriverService_IUnionResolveType = IDriverService['unionResolveType']; 9 | export type IDriverService_ISetSecrets = IDriverService['setSecrets']; 10 | export type IDriverService_IStream = IDriverService['stream']; 11 | export type IDriverService_IStdout = IDriverService['stdout']; 12 | export type IDriverService_IStderr = IDriverService['stderr']; 13 | export type IDriverService_ISubscriptionConnection = IDriverService['subscriptionConnection']; 14 | export type IDriverService_ISubscriptionListen = IDriverService['subscriptionListen']; 15 | 16 | export class DriverClient extends driverService.DriverClient {} 17 | export type IDriverServer = driverService.IDriverServer; 18 | export type IDriverClient = driverService.IDriverClient; 19 | export const DriverService = driverService.DriverService; 20 | -------------------------------------------------------------------------------- /src/proto/driver/index.ts: -------------------------------------------------------------------------------- 1 | import * as grpc from '@grpc/grpc-js'; 2 | import { 3 | AuthorizeInput, 4 | AuthorizeOutput, 5 | FieldResolveInput, 6 | FieldResolveOutput, 7 | InterfaceResolveTypeInput, 8 | InterfaceResolveTypeOutput, 9 | SetSecretsInput, 10 | SetSecretsOutput, 11 | OperationDefinition as APIOperationDefinition, 12 | ResponsePath as APIResponsePath, 13 | ScalarParseInput, 14 | ScalarParseOutput, 15 | ScalarSerializeInput, 16 | ScalarSerializeOutput, 17 | UnionResolveTypeInput, 18 | UnionResolveTypeOutput, 19 | TypeRef as APITypeRef, 20 | SubscriptionConnectionInput, 21 | SubscriptionConnectionOutput, 22 | SubscriptionListenInput, 23 | SubscriptionListenEmitter, 24 | } from '../../api/index.js'; 25 | import * as messages from './messages.js'; 26 | import { RecordOfUnknown, RecordOfValues, getFromValue, getRecordFromValueMap, valueFromAny } from './value.js'; 27 | import { getProtocol } from './protocol.js'; 28 | import { getSource } from './source.js'; 29 | import { buildTypeRef } from './type_ref.js'; 30 | import { buildResponsePath } from './response_path.js'; 31 | import { buildOperationDefinition } from './operation.js'; 32 | import { EventEmitter } from 'events'; 33 | 34 | interface ProtoInfoLike { 35 | getFieldname: typeof messages.FieldResolveInfo.prototype.getFieldname; 36 | hasPath: typeof messages.FieldResolveInfo.prototype.hasPath; 37 | getPath: typeof messages.FieldResolveInfo.prototype.getPath; 38 | getReturntype: typeof messages.FieldResolveInfo.prototype.getReturntype; 39 | hasParenttype: typeof messages.FieldResolveInfo.prototype.hasParenttype; 40 | getParenttype: typeof messages.FieldResolveInfo.prototype.getParenttype; 41 | hasOperation: typeof messages.FieldResolveInfo.prototype.hasOperation; 42 | getOperation: typeof messages.FieldResolveInfo.prototype.getOperation; 43 | getVariablevaluesMap: typeof messages.FieldResolveInfo.prototype.getVariablevaluesMap; 44 | } 45 | 46 | interface InfoLike { 47 | fieldName: string; 48 | path?: APIResponsePath; 49 | returnType?: APITypeRef; 50 | parentType?: APITypeRef; 51 | operation?: APIOperationDefinition; 52 | variableValues?: Record; 53 | } 54 | 55 | interface Type { 56 | type: string | (() => string); 57 | } 58 | 59 | interface ResponseLike { 60 | response: unknown; 61 | error?: Error; 62 | } 63 | 64 | function isType( 65 | v: 66 | | { 67 | type?: unknown; 68 | } 69 | | unknown, 70 | ): v is Type { 71 | return ( 72 | typeof v === 'object' && 73 | !!v && 74 | 'type' in v && 75 | (typeof (v as { type?: unknown }).type === 'string' || typeof (v as { type?: unknown }).type === 'function') 76 | ); 77 | } 78 | 79 | interface HasError { 80 | error: Error; 81 | } 82 | 83 | function hasError( 84 | v: 85 | | { 86 | error?: Error; 87 | } 88 | | unknown, 89 | ): v is HasError { 90 | return typeof v === 'object' && !!v && 'error' in v; 91 | } 92 | 93 | interface Response { 94 | response: unknown; 95 | } 96 | 97 | const isResponse = ( 98 | v: 99 | | { 100 | response?: unknown; 101 | } 102 | | unknown, 103 | ): v is Response => 104 | typeof v === 'object' && !!v && 'response' in v && (v as { response?: unknown }).response !== undefined; 105 | 106 | function mapVariables(infoLike: ProtoInfoLike): RecordOfValues { 107 | if (!infoLike.hasOperation()) { 108 | return {}; 109 | } 110 | const op = infoLike.getOperation(); 111 | if (!op) { 112 | return {}; 113 | } 114 | const values = infoLike.getVariablevaluesMap(); 115 | return op 116 | .getVariabledefinitionsList() 117 | .filter((v) => v.getVariable()) 118 | .reduce((pv, cv) => { 119 | const name = cv.getVariable()?.getName(); 120 | if (!name) { 121 | return pv; 122 | } 123 | const v = values.get(name) || cv.getDefaultvalue(); 124 | if (v) { 125 | pv[name] = v; 126 | } 127 | return pv; 128 | }, {} as RecordOfValues); 129 | } 130 | 131 | function mustGetInfo(req: { getInfo: () => ProtoInfoLike | undefined }): ProtoInfoLike { 132 | const info = req.getInfo(); 133 | if (info === undefined) { 134 | throw new Error('info is required'); 135 | } 136 | return info; 137 | } 138 | 139 | function protoInfoLikeToInfoLike(info: ProtoInfoLike, variables: RecordOfValues): InfoLike { 140 | const operation = info.hasOperation() && buildOperationDefinition(info.getOperation(), variables); 141 | const parentType = info.hasParenttype() && buildTypeRef(info.getParenttype()); 142 | const path = info.hasPath() && buildResponsePath(info.getPath()); 143 | const variableValues = getRecordFromValueMap(info.getVariablevaluesMap()); 144 | return { 145 | ...(operation && { operation }), 146 | ...(parentType && { parentType }), 147 | ...(path && { path }), 148 | ...(Object.keys(variableValues).length > 0 && { variableValues }), 149 | fieldName: info.getFieldname(), 150 | returnType: buildTypeRef(info.getReturntype()), 151 | }; 152 | } 153 | 154 | function valueFromResponse( 155 | out?: (RecordOfUnknown & ResponseLike) | (() => unknown) | unknown, 156 | ): messages.Value | undefined { 157 | if (!isResponse(out) && !hasError(out)) { 158 | out = { response: out }; 159 | } 160 | let responseData: unknown; 161 | if (isResponse(out)) { 162 | responseData = out.response; 163 | } 164 | return valueFromAny(responseData); 165 | } 166 | 167 | function errorFromHandlerError(err: Error): messages.Error | undefined { 168 | const protoErr = new messages.Error(); 169 | protoErr.setMsg(err.message || 'unknown error'); 170 | return protoErr; 171 | } 172 | 173 | const hasMessage = (e: unknown): e is { message: string } => 174 | !!e && typeof e === 'object' && typeof (e as { message: unknown }).message === 'string'; 175 | 176 | export function makeProtoError(e: { message: string } | unknown): messages.Error { 177 | const err = new messages.Error(); 178 | let msg = 'unknown error'; 179 | if (hasMessage(e)) { 180 | msg = e.message; 181 | } 182 | err.setMsg(msg); 183 | return err; 184 | } 185 | 186 | interface HandlerResponse { 187 | set(_2: DriverOutput): void; 188 | input(req: RequestType): DriverInput; 189 | setError: (value?: messages.Error) => void; 190 | } 191 | 192 | async function callHandler< 193 | RequestType, 194 | DriverInput, 195 | DriverOutput, 196 | ResponseType extends HandlerResponse, 197 | >(resp: ResponseType, handler: (x: DriverInput) => Promise, request: RequestType): Promise { 198 | try { 199 | const out = await handler(resp.input(request)); 200 | resp.set(out); 201 | if (hasError(out)) { 202 | resp.setError(errorFromHandlerError(out.error)); 203 | } 204 | } catch (e) { 205 | resp.setError(makeProtoError(e)); 206 | } 207 | return resp; 208 | } 209 | 210 | class SettableFieldResolveResponse extends messages.FieldResolveResponse { 211 | set(out?: ResponseLike | (() => unknown) | unknown): void { 212 | const val = valueFromResponse(out); 213 | if (val) { 214 | this.setResponse(val); 215 | } 216 | } 217 | input(req: messages.FieldResolveRequest): FieldResolveInput { 218 | const info = mustGetInfo(req); 219 | const variables = mapVariables(info); 220 | const protocol = getProtocol(req); 221 | const subscriptionPayload = getFromValue(req.getSubscriptionpayload()); 222 | const source = getSource(req); 223 | const args = getRecordFromValueMap(req.getArgumentsMap(), variables); 224 | const rootValue = getFromValue(req.getInfo()?.getRootvalue()); 225 | return { 226 | ...(Object.keys(args).length > 0 && { arguments: args }), 227 | ...(protocol && { protocol }), 228 | ...(subscriptionPayload !== undefined && { subscriptionPayload }), 229 | ...(source !== undefined && { source }), 230 | info: { 231 | ...protoInfoLikeToInfoLike(info, variables), 232 | ...(rootValue !== undefined && { rootValue }), 233 | }, 234 | }; 235 | } 236 | } 237 | 238 | interface SettableResolveTypeResponseInputReturn { 239 | info: InfoLike; 240 | value?: unknown; 241 | } 242 | type SettableResolveTypeResponseSetOutArg = 243 | | { 244 | type?: string | (() => string); 245 | error?: Error; 246 | } 247 | | (() => string) 248 | | string; 249 | 250 | interface SettableResolveTypeResponseType { 251 | setType(t: messages.TypeRef | undefined): unknown; 252 | set(out?: SettableResolveTypeResponseSetOutArg): unknown; 253 | input(req: T): SettableResolveTypeResponseInputReturn; 254 | } 255 | class SettableResolveTypeResponse< 256 | T extends { 257 | getInfo: () => ProtoInfoLike | undefined; 258 | getValue: () => messages.Value | undefined; 259 | }, 260 | > { 261 | constructor(private typeResponse: SettableResolveTypeResponseType) {} 262 | set(out?: SettableResolveTypeResponseSetOutArg): void { 263 | if (typeof out === 'function') { 264 | out = { type: out() }; 265 | } else if (typeof out === 'string') { 266 | out = { type: out }; 267 | } 268 | let type = ''; 269 | if (isType(out)) { 270 | if (typeof out.type === 'function') { 271 | out.type = out.type(); 272 | } 273 | type = out.type; 274 | } 275 | if (!type) { 276 | if (hasError(out)) { 277 | return; 278 | } 279 | throw new Error('type cannot be empty'); 280 | } 281 | const t = new messages.TypeRef(); 282 | t.setName(type); 283 | this.typeResponse.setType(t); 284 | } 285 | input(req: T): SettableResolveTypeResponseInputReturn { 286 | const info = mustGetInfo(req); 287 | const variables = mapVariables(info); 288 | return { 289 | info: protoInfoLikeToInfoLike(info, variables), 290 | value: getFromValue(req.getValue()), 291 | }; 292 | } 293 | } 294 | 295 | class SettableInterfaceResolveTypeResponse extends messages.InterfaceResolveTypeResponse { 296 | _impl: SettableResolveTypeResponse; 297 | constructor() { 298 | super(); 299 | this._impl = new SettableResolveTypeResponse(this); 300 | } 301 | set(out?: SettableResolveTypeResponseSetOutArg): void { 302 | this._impl.set(out); 303 | } 304 | input(req: messages.InterfaceResolveTypeRequest): InterfaceResolveTypeInput { 305 | return this._impl.input(req); 306 | } 307 | } 308 | 309 | class SettableUnionResolveTypeResponse extends messages.UnionResolveTypeResponse { 310 | _impl: SettableResolveTypeResponse; 311 | constructor() { 312 | super(); 313 | this._impl = new SettableResolveTypeResponse(this); 314 | } 315 | set(out?: SettableResolveTypeResponseSetOutArg): void { 316 | this._impl.set(out); 317 | } 318 | input(req: messages.UnionResolveTypeRequest): UnionResolveTypeInput { 319 | return this._impl.input(req); 320 | } 321 | } 322 | 323 | class SettableSecretsResponse extends messages.SetSecretsResponse { 324 | set(): void { 325 | // no op, implements messages.SetSecretsResponse 326 | } 327 | input(req: messages.SetSecretsRequest): SetSecretsInput { 328 | return req.getSecretsList().reduce( 329 | (pv, cv) => { 330 | pv.secrets[cv.getKey()] = cv.getValue(); 331 | return pv; 332 | }, 333 | { 334 | secrets: {} as Record, 335 | }, 336 | ); 337 | } 338 | } 339 | 340 | interface SettableScalarResponseInputReturn { 341 | value: unknown; 342 | } 343 | 344 | type SettableScalarResponseSetOutArg = 345 | | { 346 | value?: messages.Value | (() => messages.Value); 347 | error?: Error; 348 | } 349 | | (() => string) 350 | | string; 351 | 352 | interface SettableScalarResponseValue { 353 | setValue(v?: messages.Value): unknown; 354 | set(out?: SettableScalarResponseSetOutArg): unknown; 355 | input(req: T): SettableScalarResponseInputReturn; 356 | } 357 | class SettableScalarResponse< 358 | T extends { 359 | getValue: () => messages.Value | undefined; 360 | }, 361 | > { 362 | constructor(private typeResponse: SettableScalarResponseValue) {} 363 | set(out?: SettableScalarResponseSetOutArg): void { 364 | const val = valueFromResponse(out); 365 | if (val) { 366 | this.typeResponse.setValue(val); 367 | } 368 | } 369 | input(req: T): SettableScalarResponseInputReturn { 370 | return { 371 | value: getFromValue(req.getValue()), 372 | }; 373 | } 374 | } 375 | 376 | class SettableScalarParseResponse extends messages.ScalarParseResponse { 377 | _impl: SettableScalarResponse; 378 | constructor() { 379 | super(); 380 | this._impl = new SettableScalarResponse(this); 381 | } 382 | set(out?: SettableScalarResponseSetOutArg): void { 383 | return this._impl.set(out); 384 | } 385 | input(req: messages.ScalarParseRequest): ScalarParseInput { 386 | return this._impl.input(req); 387 | } 388 | } 389 | 390 | class SettableScalarSerializeResponse extends messages.ScalarSerializeResponse { 391 | _impl: SettableScalarResponse; 392 | constructor() { 393 | super(); 394 | this._impl = new SettableScalarResponse(this); 395 | } 396 | set(out?: SettableScalarResponseSetOutArg): void { 397 | return this._impl.set(out); 398 | } 399 | input(req: messages.ScalarSerializeRequest): ScalarSerializeInput { 400 | return this._impl.input(req); 401 | } 402 | } 403 | 404 | class SettableSubcriptionConnectionResponse extends messages.SubscriptionConnectionResponse { 405 | set(out?: ResponseLike | (() => unknown) | unknown): void { 406 | const val = valueFromResponse(out); 407 | if (val) { 408 | this.setResponse(val); 409 | } 410 | } 411 | input(req: messages.SubscriptionConnectionRequest): SubscriptionConnectionInput { 412 | const variableValues = getRecordFromValueMap(req.getVariablevaluesMap()); 413 | const protocol = getProtocol(req); 414 | const query = req.getQuery(); 415 | const operationName = req.getOperationname(); 416 | return { 417 | query, 418 | variableValues, 419 | operationName, 420 | protocol, 421 | }; 422 | } 423 | } 424 | 425 | export const fieldResolve = ( 426 | req: messages.FieldResolveRequest, 427 | handler: (x: FieldResolveInput) => Promise, 428 | ): Promise => callHandler(new SettableFieldResolveResponse(), handler, req); 429 | 430 | export const interfaceResolveType = ( 431 | req: messages.InterfaceResolveTypeRequest, 432 | handler: (x: InterfaceResolveTypeInput) => Promise, 433 | ): Promise => 434 | callHandler(new SettableInterfaceResolveTypeResponse(), handler, req); 435 | 436 | export const setSecrets = ( 437 | req: messages.SetSecretsRequest, 438 | handler: (x: SetSecretsInput) => Promise, 439 | ): Promise => callHandler(new SettableSecretsResponse(), handler, req); 440 | 441 | export const scalarParse = ( 442 | req: messages.ScalarParseRequest, 443 | handler: (x: ScalarParseInput) => Promise, 444 | ): Promise => callHandler(new SettableScalarParseResponse(), handler, req); 445 | 446 | export const scalarSerialize = ( 447 | req: messages.ScalarSerializeRequest, 448 | handler: (x: ScalarSerializeInput) => Promise, 449 | ): Promise => callHandler(new SettableScalarSerializeResponse(), handler, req); 450 | 451 | export const unionResolveType = ( 452 | req: messages.UnionResolveTypeRequest, 453 | handler: (x: UnionResolveTypeInput) => Promise, 454 | ): Promise => callHandler(new SettableUnionResolveTypeResponse(), handler, req); 455 | 456 | export const subscriptionConnection = ( 457 | req: messages.SubscriptionConnectionRequest, 458 | handler: (x: SubscriptionConnectionInput) => Promise, 459 | ): Promise => 460 | callHandler(new SettableSubcriptionConnectionResponse(), handler, req); 461 | 462 | class Emitter { 463 | private eventEmitter: EventEmitter; 464 | constructor( 465 | private srv: grpc.ServerWritableStream, 466 | ) { 467 | this.eventEmitter = new EventEmitter(); 468 | srv.on('close', () => { 469 | this.eventEmitter.emit('close'); 470 | }); 471 | srv.on('error', (err: Error) => { 472 | this.eventEmitter.emit('close', err); 473 | }); 474 | } 475 | async emit(v?: unknown): Promise { 476 | const msg = new messages.SubscriptionListenMessage(); 477 | msg.setNext(true); 478 | if (v !== undefined) { 479 | msg.setPayload(valueFromAny(v)); 480 | } 481 | await new Promise((resolve, reject) => 482 | this.srv.write(msg, (e: unknown) => { 483 | if (e) { 484 | reject(e); 485 | } 486 | resolve(); 487 | }), 488 | ); 489 | } 490 | 491 | on(ev: 'close', handler: (err?: Error) => void): void { 492 | this.eventEmitter.on(ev, handler); 493 | } 494 | off(ev: 'close', handler: (err?: Error) => void): void { 495 | this.eventEmitter.off(ev, handler); 496 | } 497 | } 498 | 499 | export const subscriptionListen = ( 500 | srv: grpc.ServerWritableStream, 501 | handler: (x: SubscriptionListenInput, emit: SubscriptionListenEmitter) => Promise, 502 | ): Promise => 503 | handler( 504 | { 505 | query: srv.request.getQuery(), 506 | variableValues: getRecordFromValueMap(srv.request.getVariablevaluesMap()), 507 | operationName: srv.request.getOperationname(), 508 | protocol: getProtocol(srv.request), 509 | ...(srv.request.hasOperation() && { operation: buildOperationDefinition(srv.request.getOperation(), {}) }), 510 | }, 511 | new Emitter(srv), 512 | ); 513 | 514 | class SettableAuthorizeResponse extends messages.AuthorizeResponse { 515 | set(v: boolean): void { 516 | this.setResponse(v); 517 | } 518 | input(req: messages.AuthorizeRequest): AuthorizeInput { 519 | return { 520 | query: req.getQuery(), 521 | operationName: req.getOperationname(), 522 | variableValues: getRecordFromValueMap(req.getVariablevaluesMap()), 523 | protocol: getProtocol(req), 524 | }; 525 | } 526 | } 527 | 528 | export const authorize = ( 529 | req: messages.AuthorizeRequest, 530 | handler: (x: AuthorizeInput) => Promise, 531 | ): Promise => callHandler(new SettableAuthorizeResponse(), handler, req); 532 | -------------------------------------------------------------------------------- /src/proto/driver/messages.ts: -------------------------------------------------------------------------------- 1 | import { messages } from 'stucco-ts-proto-gen'; 2 | 3 | export class ObjectValue extends messages.ObjectValue {} 4 | export class ArrayValue extends messages.ArrayValue {} 5 | export class Value extends messages.Value {} 6 | export class Error extends messages.Error {} 7 | export class Function extends messages.Function {} 8 | export class TypeRef extends messages.TypeRef {} 9 | export class ResponsePath extends messages.ResponsePath {} 10 | export class Variable extends messages.Variable {} 11 | export class VariableDefinition extends messages.VariableDefinition {} 12 | export class Directive extends messages.Directive {} 13 | export class FragmentDefinition extends messages.FragmentDefinition {} 14 | export class Selection extends messages.Selection {} 15 | export class OperationDefinition extends messages.OperationDefinition {} 16 | export class FieldResolveInfo extends messages.FieldResolveInfo {} 17 | export class FieldResolveRequest extends messages.FieldResolveRequest {} 18 | export class FieldResolveResponse extends messages.FieldResolveResponse {} 19 | export class InterfaceResolveTypeInfo extends messages.InterfaceResolveTypeInfo {} 20 | export class InterfaceResolveTypeRequest extends messages.InterfaceResolveTypeRequest {} 21 | export class InterfaceResolveTypeResponse extends messages.InterfaceResolveTypeResponse {} 22 | export class ScalarParseRequest extends messages.ScalarParseRequest {} 23 | export class ScalarParseResponse extends messages.ScalarParseResponse {} 24 | export class ScalarSerializeRequest extends messages.ScalarSerializeRequest {} 25 | export class ScalarSerializeResponse extends messages.ScalarSerializeResponse {} 26 | export class UnionResolveTypeInfo extends messages.UnionResolveTypeInfo {} 27 | export class UnionResolveTypeRequest extends messages.UnionResolveTypeRequest {} 28 | export class UnionResolveTypeResponse extends messages.UnionResolveTypeResponse {} 29 | export class Secret extends messages.Secret {} 30 | export class SetSecretsRequest extends messages.SetSecretsRequest {} 31 | export class SetSecretsResponse extends messages.SetSecretsResponse {} 32 | export class StreamInfo extends messages.StreamInfo {} 33 | export class StreamRequest extends messages.StreamRequest {} 34 | export class StreamMessage extends messages.StreamMessage {} 35 | export class ByteStreamRequest extends messages.ByteStreamRequest {} 36 | export class ByteStream extends messages.ByteStream {} 37 | export class SubscriptionConnectionRequest extends messages.SubscriptionConnectionRequest {} 38 | export class SubscriptionConnectionResponse extends messages.SubscriptionConnectionResponse {} 39 | export class SubscriptionListenRequest extends messages.SubscriptionListenRequest {} 40 | export class SubscriptionListenMessage extends messages.SubscriptionListenMessage {} 41 | export class AuthorizeRequest extends messages.AuthorizeRequest {} 42 | export class AuthorizeResponse extends messages.AuthorizeResponse {} 43 | 44 | export function anyValue(data: Uint8Array): messages.Value { 45 | const v = new messages.Value(); 46 | v.setAny(data); 47 | return v; 48 | } 49 | 50 | export function nilValue(): messages.Value { 51 | const v = new messages.Value(); 52 | v.setNil(true); 53 | return v; 54 | } 55 | 56 | export function variableValue(variable: string): messages.Value { 57 | const v = new messages.Value(); 58 | v.setVariable(variable); 59 | return v; 60 | } 61 | 62 | export function stringValue(s: string): messages.Value { 63 | const v = new messages.Value(); 64 | v.setS(s); 65 | return v; 66 | } 67 | 68 | export function intValue(i: number): messages.Value { 69 | const v = new messages.Value(); 70 | v.setI(i); 71 | return v; 72 | } 73 | 74 | export function uintValue(u: number): messages.Value { 75 | const v = new messages.Value(); 76 | v.setU(u); 77 | return v; 78 | } 79 | 80 | export function floatValue(f: number): messages.Value { 81 | const v = new messages.Value(); 82 | v.setF(f); 83 | return v; 84 | } 85 | 86 | export function booleanValue(b: boolean): messages.Value { 87 | const v = new messages.Value(); 88 | v.setB(b); 89 | return v; 90 | } 91 | 92 | export function arrValue(items: messages.Value[]): messages.Value { 93 | const v = new messages.Value(); 94 | v.setA(items.reduce((pv, cv) => (pv.addItems(cv) && pv) || pv, new messages.ArrayValue())); 95 | return v; 96 | } 97 | 98 | export function objValue(items: Record): messages.Value { 99 | const v = new messages.Value(); 100 | v.setO(Object.keys(items).reduce((pv, cv) => pv.getPropsMap().set(cv, items[cv]) && pv, new messages.ObjectValue())); 101 | return v; 102 | } 103 | -------------------------------------------------------------------------------- /src/proto/driver/operation.ts: -------------------------------------------------------------------------------- 1 | import { OperationDefinition as APIOperationDefinition } from '../../api/index.js'; 2 | import * as messages from './messages'; 3 | import { RecordOfValues } from './value.js'; 4 | import { buildDirectives } from './directive.js'; 5 | import { buildSelections } from './selection.js'; 6 | import { buildVariableDefinitions } from './variable_definition.js'; 7 | 8 | export function buildOperationDefinition( 9 | od: messages.OperationDefinition | undefined, 10 | variables: RecordOfValues, 11 | ): APIOperationDefinition | undefined { 12 | if (od) { 13 | const directives = buildDirectives(od.getDirectivesList(), variables); 14 | const selectionSet = buildSelections(od.getSelectionsetList(), variables); 15 | const variableDefinitions = buildVariableDefinitions(od.getVariabledefinitionsList()); 16 | return { 17 | ...(directives && { directives }), 18 | ...(selectionSet && { selectionSet }), 19 | ...(variableDefinitions && { variableDefinitions }), 20 | name: od.getName(), 21 | operation: od.getOperation(), 22 | }; 23 | } 24 | return; 25 | } 26 | -------------------------------------------------------------------------------- /src/proto/driver/protocol.ts: -------------------------------------------------------------------------------- 1 | import { HttpRequest, HttpRequestURL } from '../../api/index.js'; 2 | import { getFromValue, RecordOfUnknown } from './value.js'; 3 | import * as messages from './messages.js'; 4 | 5 | type Headers = Record; 6 | 7 | const newOptionalCheck = 8 | (check: (v: unknown) => v is T) => 9 | (v: unknown | undefined | null): v is T | undefined | null => 10 | v === undefined || v === null || check(v); 11 | 12 | const checkOptionalString = newOptionalCheck((v: unknown): v is string => typeof v === 'string'); 13 | const checkOptionalHeaders = newOptionalCheck((v: unknown): v is Headers => { 14 | if (typeof v !== 'object' || v === null) return false; 15 | const nv = v as RecordOfUnknown; 16 | return ( 17 | Object.keys(nv).find((k) => { 18 | const header = nv[k]; 19 | if (!Array.isArray(header)) { 20 | return true; 21 | } 22 | return header.find((el) => typeof el !== 'string'); 23 | }) === undefined 24 | ); 25 | }); 26 | 27 | function isHttpRequestURL(url: unknown): url is HttpRequestURL { 28 | if (typeof url !== 'object') { 29 | return false; 30 | } 31 | const { host, path, query, scheme } = url as { 32 | scheme?: unknown; 33 | host?: unknown; 34 | path?: unknown; 35 | query?: unknown; 36 | }; 37 | return ( 38 | checkOptionalString(host) && checkOptionalString(path) && checkOptionalString(query) && checkOptionalString(scheme) 39 | ); 40 | } 41 | 42 | const checkOptionalHttpRequestURL = newOptionalCheck((v: unknown): v is HttpRequestURL => isHttpRequestURL(v)); 43 | 44 | function isHttpRequestProtocol(protocol: unknown): protocol is Omit & { body?: string } { 45 | if (typeof protocol !== 'object' || protocol === null) { 46 | return false; 47 | } 48 | if (!('headers' in protocol)) { 49 | return false; 50 | } 51 | const { headers, body, host, method, proto, remoteAddress, url } = protocol as { 52 | headers?: unknown; 53 | body?: unknown; 54 | host?: unknown; 55 | method?: unknown; 56 | proto?: unknown; 57 | remoteAddress?: unknown; 58 | url?: unknown; 59 | }; 60 | // If none of the properties are present, return false 61 | if (!headers && !body && !host && !method && !proto && !remoteAddress && !url) { 62 | return false; 63 | } 64 | return ( 65 | checkOptionalString(body) || 66 | checkOptionalString(host) || 67 | checkOptionalString(method) || 68 | checkOptionalString(proto) || 69 | checkOptionalString(remoteAddress) || 70 | checkOptionalHttpRequestURL(url) || 71 | checkOptionalHeaders(headers) 72 | ); 73 | } 74 | 75 | interface WithProtocol { 76 | hasProtocol(): boolean; 77 | getProtocol(): messages.Value | undefined; 78 | } 79 | export function getProtocol(req: WithProtocol): HttpRequest | undefined { 80 | if (!req.hasProtocol()) { 81 | return undefined; 82 | } 83 | const protocol = getFromValue(req.getProtocol()); 84 | if (isHttpRequestProtocol(protocol)) { 85 | const { body, ...rest } = protocol; 86 | return { 87 | ...rest, 88 | ...(body && { body: Buffer.from(body, 'base64') }), 89 | }; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/proto/driver/response_path.ts: -------------------------------------------------------------------------------- 1 | import { ResponsePath as APIResponsePath } from '../../api/index.js'; 2 | import * as messages from './messages.js'; 3 | import { getFromValue } from './value.js'; 4 | 5 | export const buildResponsePath = (rp: messages.ResponsePath | undefined): APIResponsePath | undefined => 6 | rp && { 7 | key: getFromValue(rp.getKey()), 8 | ...(rp.getPrev() ? { prev: buildResponsePath(rp.getPrev()) } : {}), 9 | }; 10 | -------------------------------------------------------------------------------- /src/proto/driver/selection.ts: -------------------------------------------------------------------------------- 1 | import { Selections, Selection as APISelection } from '../../api/index.js'; 2 | import { RecordOfValues, getRecordFromValueMap } from './value.js'; 3 | import * as messages from './messages.js'; 4 | import { buildDirectives } from './directive.js'; 5 | import { buildTypeRef } from './type_ref.js'; 6 | import { notUndefined } from '../../util/util.js'; 7 | 8 | function buildFragmentSelection( 9 | fragment?: messages.FragmentDefinition, 10 | variables?: RecordOfValues, 11 | ): APISelection | undefined { 12 | if (!fragment) { 13 | return undefined; 14 | } 15 | const directives = buildDirectives(fragment.getDirectivesList(), variables); 16 | return { 17 | definition: { 18 | selectionSet: buildSelections(fragment.getSelectionsetList(), variables) || [], 19 | typeCondition: buildTypeRef(fragment.getTypecondition()), 20 | ...(directives && { directives }), 21 | }, 22 | }; 23 | } 24 | 25 | function buildFieldSelection(selection: messages.Selection, variables?: RecordOfValues): APISelection | undefined { 26 | const name = selection.getName(); 27 | if (!name) { 28 | return; 29 | } 30 | const args = getRecordFromValueMap(selection.getArgumentsMap(), variables); 31 | const directives = buildDirectives(selection.getDirectivesList(), variables); 32 | const selectionSet = buildSelections(selection.getSelectionsetList(), variables); 33 | return { 34 | name, 35 | ...(Object.keys(args).length > 0 && { arguments: args }), 36 | ...(directives && { directives }), 37 | ...(selectionSet && { selectionSet }), 38 | }; 39 | } 40 | 41 | function buildSelection(selection: messages.Selection, variables?: RecordOfValues): APISelection | undefined { 42 | return buildFieldSelection(selection, variables) || buildFragmentSelection(selection.getDefinition(), variables); 43 | } 44 | 45 | export const buildSelections = ( 46 | selectionSet?: messages.Selection[], 47 | variables?: RecordOfValues, 48 | ): Selections | undefined => 49 | Array.isArray(selectionSet) && selectionSet.length > 0 50 | ? selectionSet.map((sel) => buildSelection(sel, variables)).filter(notUndefined) 51 | : undefined; 52 | -------------------------------------------------------------------------------- /src/proto/driver/source.ts: -------------------------------------------------------------------------------- 1 | import * as messages from './messages.js'; 2 | import { getFromValue } from './value.js'; 3 | 4 | interface WithSource { 5 | hasSource(): boolean; 6 | getSource(): messages.Value | undefined; 7 | } 8 | 9 | export const getSource = (req: WithSource): unknown => (req.hasSource() ? getFromValue(req.getSource()) : undefined); 10 | -------------------------------------------------------------------------------- /src/proto/driver/type_ref.ts: -------------------------------------------------------------------------------- 1 | import { TypeRef as APITypeRef } from '../../api/index.js'; 2 | import * as messages from './messages.js'; 3 | 4 | export function buildTypeRef(tr: messages.TypeRef | undefined): APITypeRef | undefined { 5 | if (!tr) { 6 | return; 7 | } 8 | const name = tr.getName(); 9 | if (name) { 10 | return { name }; 11 | } 12 | const nonNull = buildTypeRef(tr.getNonnull()); 13 | if (nonNull) { 14 | return { nonNull }; 15 | } 16 | const list = buildTypeRef(tr.getList()); 17 | if (list) { 18 | return { list }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/proto/driver/value.ts: -------------------------------------------------------------------------------- 1 | import * as jspb from 'google-protobuf'; 2 | import * as messages from './messages.js'; 3 | 4 | export type RecordOfValues = Record; 5 | export type RecordOfUnknown = Record; 6 | 7 | export function valueFromAny(data: unknown): messages.Value { 8 | const val = new messages.Value(); 9 | if (data === null || typeof data === 'undefined') { 10 | val.setNil(true); 11 | } else if (Buffer.isBuffer(data)) { 12 | val.setAny(Uint8Array.from(data)); 13 | } else if (ArrayBuffer.isView(data)) { 14 | val.setAny(new Uint8Array(data.buffer)); 15 | } else if (Array.isArray(data)) { 16 | val.setA(data.reduce((pv, cv) => pv.addItems(valueFromAny(cv)) && pv, new messages.ArrayValue())); 17 | } else { 18 | switch (typeof data) { 19 | case 'number': 20 | if (data % 1 === 0) { 21 | val.setI(data); 22 | } else { 23 | val.setF(data); 24 | } 25 | break; 26 | case 'string': 27 | val.setS(data); 28 | break; 29 | case 'boolean': 30 | val.setB(data); 31 | break; 32 | case 'object': 33 | val.setO( 34 | Object.keys(data as RecordOfUnknown).reduce( 35 | (pv, cv) => pv.getPropsMap().set(cv, valueFromAny((data as RecordOfUnknown)[cv])) && pv, 36 | new messages.ObjectValue(), 37 | ), 38 | ); 39 | break; 40 | } 41 | } 42 | return val; 43 | } 44 | 45 | export function getFromValue(value?: messages.Value, variables?: RecordOfValues): unknown | undefined { 46 | if (typeof value === 'undefined') { 47 | return; 48 | } 49 | if (value.hasNil()) { 50 | return null; 51 | } 52 | if (value.hasI()) { 53 | return value.getI(); 54 | } 55 | if (value.hasU()) { 56 | return value.getU(); 57 | } 58 | if (value.hasF()) { 59 | return value.getF(); 60 | } 61 | if (value.hasS()) { 62 | return value.getS(); 63 | } 64 | if (value.hasB()) { 65 | return value.getB(); 66 | } 67 | if (value.hasO()) { 68 | const props = value?.getO()?.getPropsMap(); 69 | if (props) { 70 | return getRecordFromValueMap(props); 71 | } 72 | return; 73 | } 74 | if (value.hasA()) { 75 | const items = value?.getA()?.getItemsList(); 76 | if (items) { 77 | return items.map((v) => getFromValue(v)); 78 | } 79 | return; 80 | } 81 | if (value.hasAny()) { 82 | return value.getAny_asU8(); 83 | } 84 | if (value.hasVariable()) { 85 | if (variables) { 86 | return getFromValue(variables[value.getVariable()]); 87 | } 88 | } 89 | return; 90 | } 91 | 92 | const jspbMapReducer = 93 | (variables?: RecordOfValues) => 94 | (m: jspb.Map, out: RecordOfUnknown): RecordOfUnknown => { 95 | m.forEach((v, k) => (out[k] = getFromValue(v, variables))); 96 | return out; 97 | }; 98 | 99 | export const getRecordFromValueMap = ( 100 | m: jspb.Map, 101 | variables?: RecordOfValues, 102 | ): RecordOfUnknown => jspbMapReducer(variables)(m, {}); 103 | -------------------------------------------------------------------------------- /src/proto/driver/variable_definition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VariableDefinition as APIVariableDefinition, 3 | VariableDefinitions as APIVariableDefinitions, 4 | } from '../../api/index.js'; 5 | import * as messages from './messages.js'; 6 | import { getFromValue } from './value.js'; 7 | import { notUndefined } from '../../util/util.js'; 8 | 9 | const buildVariableDefinition = (vd: messages.VariableDefinition): APIVariableDefinition | undefined => ({ 10 | defaultValue: getFromValue(vd.getDefaultvalue()), 11 | variable: { 12 | name: vd.getVariable()?.getName() || '', 13 | }, 14 | }); 15 | 16 | export const buildVariableDefinitions = (vds: messages.VariableDefinition[]): APIVariableDefinitions | undefined => 17 | vds.length > 0 ? vds.map((vd) => buildVariableDefinition(vd)).filter(notUndefined) : undefined; 18 | -------------------------------------------------------------------------------- /src/raw/authorize.ts: -------------------------------------------------------------------------------- 1 | import { authorize } from '../proto/driver/index.js'; 2 | import * as messages from './../proto/driver/messages.js'; 3 | import { MessageType } from './message.js'; 4 | import { AuthorizeInput, AuthorizeOutput } from '../api/index.js'; 5 | import { handler } from './handler.js'; 6 | 7 | export async function fieldResolveHandler(contentType: string, body: Uint8Array): Promise { 8 | return handler( 9 | contentType, 10 | MessageType.FIELD_RESOLVE_REQUEST, 11 | messages.AuthorizeRequest, 12 | messages.AuthorizeResponse, 13 | body, 14 | authorize, 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/raw/field_resolve.ts: -------------------------------------------------------------------------------- 1 | import { fieldResolve } from '../proto/driver/index.js'; 2 | import * as messages from './../proto/driver/messages.js'; 3 | import { MessageType } from './message.js'; 4 | import { FieldResolveInput, FieldResolveOutput } from '../api/index.js'; 5 | import { handler } from './handler.js'; 6 | 7 | export async function fieldResolveHandler(contentType: string, body: Uint8Array): Promise { 8 | return handler( 9 | contentType, 10 | MessageType.FIELD_RESOLVE_REQUEST, 11 | messages.FieldResolveRequest, 12 | messages.FieldResolveResponse, 13 | body, 14 | fieldResolve, 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/raw/handler.ts: -------------------------------------------------------------------------------- 1 | import { MessageType, getMessageType, parseMIME } from './message.js'; 2 | import { getHandler, WithFunction } from '../handler/index.js'; 3 | import * as messages from './../proto/driver/messages.js'; 4 | import { makeProtoError } from '../proto/driver/index.js'; 5 | 6 | interface Deserializable { 7 | deserializeBinary(data: Uint8Array): T; 8 | } 9 | 10 | interface Constructible { 11 | new (): T; 12 | } 13 | 14 | interface Serializable { 15 | serializeBinary(): Uint8Array; 16 | } 17 | 18 | interface WithError { 19 | setError(err: messages.Error): void; 20 | } 21 | 22 | export async function handler( 23 | contentType: string, 24 | msgType: MessageType, 25 | requestType: Deserializable, 26 | responseType: Constructible, 27 | body: Uint8Array, 28 | fn: (req: T, handler: (x: V) => Promise) => Promise, 29 | ): Promise { 30 | try { 31 | if (getMessageType(parseMIME(contentType)) !== msgType) { 32 | throw new Error(`"${contentType}" is not a valid content-type`); 33 | } 34 | const request = requestType.deserializeBinary(body); 35 | const handler = await getHandler(request); 36 | const response = await fn(request, handler); 37 | return response.serializeBinary(); 38 | } catch (e) { 39 | const response = new responseType(); 40 | response.setError(makeProtoError(e)); 41 | return response.serializeBinary(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/raw/index.ts: -------------------------------------------------------------------------------- 1 | export { fieldResolveHandler } from './field_resolve.js'; 2 | export { interfaceResolveTypeHandler } from './interface_resolve_type.js'; 3 | export { scalarParseHandler, scalarSerializeHandler } from './scalar.js'; 4 | export { setSecretsHandler } from './set_secrets.js'; 5 | export { unionResolveTypeHandler } from './union_resolve_type.js'; 6 | export { subscriptionConnectionHandler } from './subscription_connection.js'; 7 | -------------------------------------------------------------------------------- /src/raw/interface_resolve_type.ts: -------------------------------------------------------------------------------- 1 | import { interfaceResolveType } from '../proto/driver/index.js'; 2 | import * as messages from './../proto/driver/messages.js'; 3 | import { MessageType } from './message.js'; 4 | import { InterfaceResolveTypeInput, InterfaceResolveTypeOutput } from '../api/index.js'; 5 | import { handler } from './handler.js'; 6 | 7 | export async function interfaceResolveTypeHandler(contentType: string, body: Uint8Array): Promise { 8 | return handler< 9 | messages.InterfaceResolveTypeRequest, 10 | messages.InterfaceResolveTypeResponse, 11 | InterfaceResolveTypeInput, 12 | InterfaceResolveTypeOutput 13 | >( 14 | contentType, 15 | MessageType.INTERFACE_RESOLVE_TYPE_REQUEST, 16 | messages.InterfaceResolveTypeRequest, 17 | messages.InterfaceResolveTypeResponse, 18 | body, 19 | interfaceResolveType, 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/raw/message.ts: -------------------------------------------------------------------------------- 1 | export enum MessageType { 2 | FIELD_RESOLVE_REQUEST, 3 | FIELD_RESOLVE_RESPONSE, 4 | INTERFACE_RESOLVE_TYPE_REQUEST, 5 | INTERFACE_RESOLVE_TYPE_RESPONSE, 6 | SET_SECRETS_REQUEST, 7 | SET_SECRETS_RESPONSE, 8 | SCALAR_PARSE_REQUEST, 9 | SCALAR_PARSE_RESPONSE, 10 | SCALAR_SERIALIZE_REQUEST, 11 | SCALAR_SERIALIZE_RESPONSE, 12 | UNION_RESOLVE_TYPE_REQUEST, 13 | UNION_RESOLVE_TYPE_RESPONSE, 14 | SUBSCRIPTION_CONNECTION_REQUEST, 15 | SUBSCRIPTION_CONNECTION_RESPONSE, 16 | } 17 | 18 | const protobufContentType = 'application/x-protobuf'; 19 | 20 | interface MIME { 21 | mimeType: string; 22 | params: { 23 | [k: string]: string | undefined; 24 | }; 25 | } 26 | 27 | export function parseMIME(mime: string): MIME | undefined { 28 | mime = mime.trim(); 29 | const parts = mime.split(/;[ ]*/); 30 | if (parts.length < 1 || !parts[0].match(/^[a-z-]*\/[a-z-]*$/)) { 31 | return; 32 | } 33 | const parsedMime: MIME = { 34 | mimeType: parts[0].toLowerCase(), 35 | params: {}, 36 | }; 37 | parts.slice(1).forEach((param) => { 38 | const split = param.indexOf('='); 39 | parsedMime.params[param.slice(0, split)] = split === -1 ? undefined : param.slice(split + 1); 40 | }); 41 | return parsedMime; 42 | } 43 | 44 | function isProtobufMessage(mime?: MIME): mime is MIME { 45 | return !!mime && mime.mimeType === protobufContentType; 46 | } 47 | 48 | export function getMessageType(mime?: MIME): MessageType | undefined { 49 | if (!isProtobufMessage(mime)) { 50 | return; 51 | } 52 | let messageType: MessageType | undefined; 53 | switch ((mime.params.message || '').toLowerCase()) { 54 | case 'fieldresolverequest': 55 | messageType = MessageType.FIELD_RESOLVE_REQUEST; 56 | break; 57 | case 'fieldresolveresponse': 58 | messageType = MessageType.FIELD_RESOLVE_RESPONSE; 59 | break; 60 | case 'interfaceresolvetyperequest': 61 | messageType = MessageType.INTERFACE_RESOLVE_TYPE_REQUEST; 62 | break; 63 | case 'interfaceresolvetyperesponse': 64 | messageType = MessageType.INTERFACE_RESOLVE_TYPE_RESPONSE; 65 | break; 66 | case 'setsecretsrequest': 67 | messageType = MessageType.SET_SECRETS_REQUEST; 68 | break; 69 | case 'setsecretsresponse': 70 | messageType = MessageType.SET_SECRETS_RESPONSE; 71 | break; 72 | case 'scalarparserequest': 73 | messageType = MessageType.SCALAR_PARSE_REQUEST; 74 | break; 75 | case 'scalarparseresponse': 76 | messageType = MessageType.SCALAR_PARSE_RESPONSE; 77 | break; 78 | case 'scalarserializerequest': 79 | messageType = MessageType.SCALAR_SERIALIZE_REQUEST; 80 | break; 81 | case 'scalarserializeresponse': 82 | messageType = MessageType.SCALAR_SERIALIZE_RESPONSE; 83 | break; 84 | case 'unionresolvetyperequest': 85 | messageType = MessageType.UNION_RESOLVE_TYPE_REQUEST; 86 | break; 87 | case 'unionresolvetyperesponse': 88 | messageType = MessageType.UNION_RESOLVE_TYPE_RESPONSE; 89 | break; 90 | case 'subscriptionconnectionrequest': 91 | messageType = MessageType.SUBSCRIPTION_CONNECTION_REQUEST; 92 | break; 93 | case 'subscriptionconnectionresponse': 94 | messageType = MessageType.SUBSCRIPTION_CONNECTION_RESPONSE; 95 | break; 96 | } 97 | return messageType; 98 | } 99 | 100 | export function messageTypeToMime(mime: MessageType): string { 101 | let messageType = protobufContentType + ';message='; 102 | switch (mime) { 103 | case MessageType.FIELD_RESOLVE_REQUEST: 104 | messageType += 'fieldresolverequest'; 105 | break; 106 | case MessageType.FIELD_RESOLVE_RESPONSE: 107 | messageType += 'fieldresolveresponse'; 108 | break; 109 | case MessageType.INTERFACE_RESOLVE_TYPE_REQUEST: 110 | messageType += 'interfaceresolvetyperequest'; 111 | break; 112 | case MessageType.INTERFACE_RESOLVE_TYPE_RESPONSE: 113 | messageType += 'interfaceresolvetyperesponse'; 114 | break; 115 | case MessageType.SET_SECRETS_REQUEST: 116 | messageType += 'setsecretsrequest'; 117 | break; 118 | case MessageType.SET_SECRETS_RESPONSE: 119 | messageType += 'setsecretsresponse'; 120 | break; 121 | case MessageType.SCALAR_PARSE_REQUEST: 122 | messageType += 'scalarparserequest'; 123 | break; 124 | case MessageType.SCALAR_PARSE_RESPONSE: 125 | messageType += 'scalarparseresponse'; 126 | break; 127 | case MessageType.SCALAR_SERIALIZE_REQUEST: 128 | messageType += 'scalarserializerequest'; 129 | break; 130 | case MessageType.SCALAR_SERIALIZE_RESPONSE: 131 | messageType += 'scalarserializeresponse'; 132 | break; 133 | case MessageType.UNION_RESOLVE_TYPE_REQUEST: 134 | messageType += 'unionresolvetyperequest'; 135 | break; 136 | case MessageType.UNION_RESOLVE_TYPE_RESPONSE: 137 | messageType += 'unionresolvetyperesponse'; 138 | break; 139 | case MessageType.SUBSCRIPTION_CONNECTION_REQUEST: 140 | messageType += 'subscriptionconnectionrequest'; 141 | break; 142 | case MessageType.SUBSCRIPTION_CONNECTION_RESPONSE: 143 | messageType += 'subscriptionconnectionresponse'; 144 | break; 145 | } 146 | return messageType; 147 | } 148 | -------------------------------------------------------------------------------- /src/raw/scalar.ts: -------------------------------------------------------------------------------- 1 | import { scalarParse, scalarSerialize } from '../proto/driver/index.js'; 2 | import * as messages from './../proto/driver/messages.js'; 3 | import { MessageType } from './message.js'; 4 | import { ScalarParseInput, ScalarParseOutput, ScalarSerializeInput, ScalarSerializeOutput } from '../api/index.js'; 5 | import { handler } from './handler.js'; 6 | 7 | export async function scalarParseHandler(contentType: string, body: Uint8Array): Promise { 8 | return handler( 9 | contentType, 10 | MessageType.SCALAR_PARSE_REQUEST, 11 | messages.ScalarParseRequest, 12 | messages.ScalarParseResponse, 13 | body, 14 | scalarParse, 15 | ); 16 | } 17 | 18 | export async function scalarSerializeHandler(contentType: string, body: Uint8Array): Promise { 19 | return handler< 20 | messages.ScalarSerializeRequest, 21 | messages.ScalarSerializeResponse, 22 | ScalarSerializeInput, 23 | ScalarSerializeOutput 24 | >( 25 | contentType, 26 | MessageType.SCALAR_SERIALIZE_REQUEST, 27 | messages.ScalarSerializeRequest, 28 | messages.ScalarSerializeResponse, 29 | body, 30 | scalarSerialize, 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/raw/set_secrets.ts: -------------------------------------------------------------------------------- 1 | import { setSecrets, makeProtoError } from '../proto/driver/index.js'; 2 | import { SetSecretsRequest, SetSecretsResponse } from '../proto/driver/messages.js'; 3 | import { getMessageType, parseMIME, MessageType } from './message.js'; 4 | import { SetSecretsOutput, SetSecretsInput } from '../api/index.js'; 5 | 6 | export async function setSecretsEnvironment(secrets: SetSecretsInput): Promise { 7 | Object.keys(secrets.secrets).forEach((k) => { 8 | process.env[k] = secrets.secrets[k]; 9 | }); 10 | return; 11 | } 12 | 13 | export async function setSecretsHandler(contentType: string, body: Uint8Array): Promise { 14 | try { 15 | if (getMessageType(parseMIME(contentType)) !== MessageType.SET_SECRETS_REQUEST) { 16 | throw new Error(`"${contentType}" is not a valid content-type`); 17 | } 18 | const request = SetSecretsRequest.deserializeBinary(body); 19 | const response = await setSecrets(request, setSecretsEnvironment); 20 | return response.serializeBinary(); 21 | } catch (e) { 22 | const response = new SetSecretsResponse(); 23 | response.setError(makeProtoError(e)); 24 | return response.serializeBinary(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/raw/subscription_connection.ts: -------------------------------------------------------------------------------- 1 | import { subscriptionConnection } from '../proto/driver/index.js'; 2 | import * as messages from './../proto/driver/messages.js'; 3 | import { MessageType } from './message.js'; 4 | import { SubscriptionConnectionInput, SubscriptionConnectionOutput } from '../api/index.js'; 5 | import { handler } from './handler.js'; 6 | 7 | export async function subscriptionConnectionHandler(contentType: string, body: Uint8Array): Promise { 8 | return handler< 9 | messages.SubscriptionConnectionRequest, 10 | messages.SubscriptionConnectionResponse, 11 | SubscriptionConnectionInput, 12 | SubscriptionConnectionOutput 13 | >( 14 | contentType, 15 | MessageType.SUBSCRIPTION_CONNECTION_REQUEST, 16 | messages.SubscriptionConnectionRequest, 17 | messages.SubscriptionConnectionResponse, 18 | body, 19 | subscriptionConnection, 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/raw/union_resolve_type.ts: -------------------------------------------------------------------------------- 1 | import { unionResolveType } from '../proto/driver/index.js'; 2 | import * as messages from './../proto/driver/messages.js'; 3 | import { MessageType } from './message.js'; 4 | import { UnionResolveTypeInput, UnionResolveTypeOutput } from '../api/index.js'; 5 | import { handler } from './handler.js'; 6 | 7 | export async function unionResolveTypeHandler(contentType: string, body: Uint8Array): Promise { 8 | return handler< 9 | messages.UnionResolveTypeRequest, 10 | messages.UnionResolveTypeResponse, 11 | UnionResolveTypeInput, 12 | UnionResolveTypeOutput 13 | >( 14 | contentType, 15 | MessageType.UNION_RESOLVE_TYPE_REQUEST, 16 | messages.UnionResolveTypeRequest, 17 | messages.UnionResolveTypeResponse, 18 | body, 19 | unionResolveType, 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/security/apiKey.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'http'; 2 | const envApiKey = process.env['STUCCO_FUNCION_KEY'] || ''; 3 | const bearerPrefix = 'bearer '; 4 | const bearerPrefixLen = bearerPrefix.length; 5 | 6 | const xStuccoApiKey = 'x-stucco-api-key'; 7 | 8 | function getAsString(v: string | string[] | undefined): string | undefined { 9 | return Array.isArray(v) ? v.join(', ') : v; 10 | } 11 | 12 | function acceptBearer(key: string | undefined): string | undefined { 13 | if (key?.trim()?.slice(0, bearerPrefixLen)?.toLowerCase() === bearerPrefix) { 14 | key = key.trim().slice(bearerPrefixLen).trim(); 15 | } 16 | return key; 17 | } 18 | 19 | export class ApiKeyAuth { 20 | constructor(private apiKey: string = envApiKey) { 21 | if (!this.apiKey) { 22 | throw new Error('empty api key'); 23 | } 24 | } 25 | async authorize(req: IncomingMessage): Promise { 26 | const key = acceptBearer(getAsString(req.headers[xStuccoApiKey] || req.headers['authorization'])); 27 | return this.apiKey === key; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/security/cert.ts: -------------------------------------------------------------------------------- 1 | import LRU from 'lru-cache'; 2 | import nodeForge from 'node-forge'; 3 | const { pki } = nodeForge; 4 | import { IncomingMessage } from 'http'; 5 | import { TLSSocket } from 'tls'; 6 | import { v4 } from 'uuid'; 7 | 8 | const maxAge = 1000 * 60 * 5; 9 | const cache = new LRU({ 10 | max: 30, 11 | maxAge: 1000 * 60 * 5, 12 | }); 13 | 14 | interface WithTLSSocket extends IncomingMessage { 15 | socket: TLSSocket; 16 | } 17 | 18 | function isWithTLSSocket(r: IncomingMessage): r is WithTLSSocket { 19 | const s = r.socket as TLSSocket; 20 | return typeof s.getPeerCertificate === 'function'; 21 | } 22 | 23 | function chunkPEM(pem: string): string { 24 | return Array.apply('', Array(Math.ceil(pem.length / 64))) 25 | .map((_, idx) => pem.slice(idx * 64, (idx + 1) * 64)) 26 | .join('\n'); 27 | } 28 | 29 | const pemData = new RegExp(/^\s*[:A-Za-z0-9+/=\s]+[:A-Za-z0-9+/=\s][:A-Za-z0-9+/=\s]$/); 30 | export function createPEM(data: string): string { 31 | // try to fix missing headers 32 | const match = pemData.test(data); 33 | if (match) { 34 | data = `\n-----BEGIN CERTIFICATE-----\n${chunkPEM(data)}\n-----END CERTIFICATE-----`; 35 | } 36 | return data; 37 | } 38 | 39 | export class HttpCertReader { 40 | async ReadCert(req: IncomingMessage): Promise { 41 | if (isWithTLSSocket(req)) { 42 | return createPEM(req.socket.getPeerCertificate(false).raw.toString('base64')); 43 | } 44 | throw new Error('request missing cert data'); 45 | } 46 | } 47 | 48 | export interface CaReader { 49 | ReadCa(): Promise; 50 | } 51 | 52 | export interface CertReader { 53 | ReadCert(req: IncomingMessage): Promise; 54 | } 55 | 56 | const envCA = process.env['STUCCO_CA'] || ''; 57 | interface ClientCertAuthOpts { 58 | ca?: string | CaReader; 59 | uuid?: string; 60 | } 61 | export class ClientCertAuth { 62 | private _store?: nodeForge.pki.CAStore; 63 | private ca: string | CaReader; 64 | private uuid: string; 65 | constructor(private certReader: CertReader, opts: ClientCertAuthOpts = {}) { 66 | const { ca = envCA, uuid = v4() } = opts; 67 | this.ca = ca; 68 | this.uuid = uuid; 69 | } 70 | private async store(): Promise { 71 | let { _store, ca } = this; 72 | if (!_store) { 73 | if (!ca) { 74 | throw new Error('empty certifacte authority'); 75 | } 76 | if (typeof ca !== 'string') { 77 | ca = await ca.ReadCa(); 78 | } 79 | const pem = pki.certificateFromPem(ca); 80 | this._store = _store = pki.createCaStore([pem]); 81 | } 82 | return _store; 83 | } 84 | async authorize(req: IncomingMessage): Promise { 85 | try { 86 | const cert = await this.certReader.ReadCert(req); 87 | const now = Date.now(); 88 | const cacheKey = `${this.uuid}-${cert}-${now - (now % maxAge)}`; 89 | if (cache.get(cacheKey)) return true; 90 | const pem = pki.certificateFromPem(cert); 91 | const store = await this.store(); 92 | const verified = pki.verifyCertificateChain(store, [pem]); 93 | if (verified) return cache.set(cacheKey, true); 94 | return verified; 95 | } catch (e) { 96 | if (process.env['DEBUG'] === '1') { 97 | console.debug(e); 98 | } 99 | return false; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/security/index.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'http'; 2 | import { HttpCertReader, ClientCertAuth } from './cert.js'; 3 | import { ApiKeyAuth } from './apiKey.js'; 4 | 5 | interface Auth { 6 | authorize(req: IncomingMessage): Promise; 7 | } 8 | 9 | export class MultiAuth { 10 | constructor(public authHandlers: Auth[]) { 11 | if (this.authHandlers.length === 0) throw new Error('no handlers'); 12 | } 13 | async authorize(req: IncomingMessage): Promise { 14 | const authResults = await Promise.all(this.authHandlers.map((h) => h.authorize(req))); 15 | return authResults.find((b) => b === true) !== undefined; 16 | } 17 | } 18 | 19 | export function DefaultSecurity(secRequired = false): MultiAuth | void { 20 | const authHandlers: Auth[] = []; 21 | if (process.env['STUCCO_CA']) { 22 | authHandlers.push(new ClientCertAuth(new HttpCertReader())); 23 | } 24 | if (process.env['STUCCO_FUNCTION_KEY']) { 25 | authHandlers.push(new ApiKeyAuth(process.env['STUCCO_FUNCTION_KEY'])); 26 | } 27 | if (authHandlers.length == 0) { 28 | if (secRequired) throw new Error('could not create default funciton security'); 29 | return; 30 | } 31 | return new MultiAuth(authHandlers); 32 | } 33 | 34 | export { ClientCertAuth, HttpCertReader, createPEM } from './cert.js'; 35 | export { ApiKeyAuth } from './apiKey.js'; 36 | -------------------------------------------------------------------------------- /src/server/hook_console.ts: -------------------------------------------------------------------------------- 1 | import { Console } from 'console'; 2 | import { format } from 'util'; 3 | 4 | interface ConsoleLike { 5 | log: typeof Console.prototype.log; 6 | info: typeof Console.prototype.info; 7 | debug: typeof Console.prototype.debug; 8 | warn: typeof Console.prototype.warn; 9 | error: typeof Console.prototype.error; 10 | trace: typeof Console.prototype.trace; 11 | } 12 | 13 | function defaultTrace(data: unknown, ...optionalParams: unknown[]): string { 14 | let constructorOpt: ((...args: unknown[]) => unknown) | undefined; 15 | if (typeof optionalParams[0] === 'function') { 16 | constructorOpt = optionalParams.shift() as (...args: unknown[]) => unknown; 17 | } 18 | const traceObj: { 19 | name: string; 20 | message?: string; 21 | stack?: string; 22 | } = { 23 | name: 'Trace', 24 | ...(typeof data !== 'undefined' && data !== '' && { message: format(data, ...optionalParams) }), 25 | }; 26 | Error.captureStackTrace(traceObj, constructorOpt); 27 | let stack = traceObj.stack; 28 | if (!('message' in traceObj)) { 29 | // keep it identical to console.trace(), remove ': ' from 'Trace: ' when there's no message 30 | stack = stack?.substr(0, 'Trace'.length).concat(stack?.substr('Trace'.length + 2)); 31 | } 32 | return stack || ''; 33 | } 34 | 35 | export interface Hooks { 36 | [k: string]: ((data: unknown, ...optionalParams: unknown[]) => string) | undefined; 37 | log?: (data: unknown, ...optionalParams: unknown[]) => string; 38 | info?: (data: unknown, ...optionalParams: unknown[]) => string; 39 | debug?: (data: unknown, ...optionalParams: unknown[]) => string; 40 | warn?: (data: unknown, ...optionalParams: unknown[]) => string; 41 | error?: (data: unknown, ...optionalParams: unknown[]) => string; 42 | trace?: (data: unknown, ...optionalParams: unknown[]) => string; 43 | } 44 | 45 | const DefaultHook: Hooks = { 46 | log: (data: unknown, ...optionalParams: unknown[]): string => '[INFO]' + format(data, ...optionalParams), 47 | info: (data: unknown, ...optionalParams: unknown[]): string => '[INFO]' + format(data, ...optionalParams), 48 | debug: (data: unknown, ...optionalParams: unknown[]): string => '[DEBUG]' + format(data, ...optionalParams), 49 | warn: (data: unknown, ...optionalParams: unknown[]): string => '[WARN]' + format(data, ...optionalParams), 50 | error: (data: unknown, ...optionalParams: unknown[]): string => '[ERROR]' + format(data, ...optionalParams), 51 | trace: (data: unknown, ...optionalParams: unknown[]): string => '[TRACE]' + defaultTrace(data, ...optionalParams), 52 | }; 53 | 54 | type hookFunctionName = 'log' | 'info' | 'debug' | 'warn' | 'error' | 'trace'; 55 | const hookFunctions: Array = ['log', 'info', 'debug', 'warn', 'error', 'trace']; 56 | 57 | export class ConsoleHook { 58 | private log: typeof Console.prototype.log; 59 | private info: typeof Console.prototype.info; 60 | private debug: typeof Console.prototype.debug; 61 | private warn: typeof Console.prototype.warn; 62 | private error: typeof Console.prototype.error; 63 | private trace: typeof Console.prototype.trace; 64 | private console: ConsoleLike; 65 | private hooked?: boolean; 66 | 67 | constructor(console: ConsoleLike, hooks: Hooks = {}) { 68 | this.log = console.log; 69 | this.info = console.info; 70 | this.debug = console.debug; 71 | this.warn = console.warn; 72 | this.error = console.error; 73 | this.trace = console.trace; 74 | this.console = console; 75 | hooks = { 76 | ...DefaultHook, 77 | ...Object.keys(hooks) 78 | .filter((k) => typeof hooks[k] === 'function') 79 | .reduce( 80 | (pv, cv) => ({ 81 | [cv]: hooks[cv]?.bind(hooks), 82 | ...pv, 83 | }), 84 | {}, 85 | ), 86 | }; 87 | if (hooks.log) { 88 | this.console.log = this.hookedCall(this.log, hooks.log); 89 | } 90 | if (hooks.info) { 91 | this.console.info = this.hookedCall(this.info, hooks.info); 92 | } 93 | if (hooks.debug) { 94 | this.console.debug = this.hookedCall(this.debug, hooks.debug); 95 | } 96 | if (hooks.warn) { 97 | this.console.warn = this.hookedCall(this.warn, hooks.warn); 98 | } 99 | if (hooks.error) { 100 | this.console.error = this.hookedCall(this.error, hooks.error); 101 | } 102 | this.console.trace = (data: unknown, ...optionalParams: unknown[]): void => { 103 | if (this.hooked && hooks.trace) { 104 | data = hooks.trace(data, this.console.trace, ...optionalParams); 105 | optionalParams = []; 106 | } else { 107 | data = defaultTrace(data, this.console.trace, ...optionalParams); 108 | } 109 | // Write trace to error output without doing anything else. 110 | // This is necessary to emulate proper stack printing behaviour, 111 | // which prints stack from the frame it was called 112 | this.error.call(this.console, data); 113 | }; 114 | } 115 | 116 | private hookedCall unknown>( 117 | fn: FunctionType, 118 | hook: FunctionType, 119 | ): (data: unknown, ...optionalParams: unknown[]) => void { 120 | return (data: unknown, ...optionalParams: unknown[]): void => { 121 | if (this.hooked) { 122 | data = hook(data, ...optionalParams); 123 | optionalParams = []; 124 | } 125 | fn.call(this.console, data, ...optionalParams); 126 | }; 127 | } 128 | 129 | hook(): void { 130 | this.hooked = true; 131 | } 132 | 133 | unhook(): void { 134 | this.hooked = false; 135 | } 136 | 137 | delete(): void { 138 | hookFunctions.forEach((fn) => { 139 | this.console[fn] = this[fn]; 140 | }); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/server/hook_stream.ts: -------------------------------------------------------------------------------- 1 | import { PassThrough, Writable } from 'stream'; 2 | 3 | type ErrorCallback = (error: Error | undefined | null) => void; 4 | type WriteFunc = Writable['write']; 5 | 6 | export interface WriteStreamLike { 7 | write: WriteFunc; 8 | } 9 | 10 | export class StreamHook extends PassThrough { 11 | private oldWrite: WriteFunc; 12 | private stream: WriteStreamLike; 13 | private hooked: boolean; 14 | constructor(stream: WriteStreamLike) { 15 | super(); 16 | this.oldWrite = stream.write; 17 | this.stream = stream; 18 | this.hooked = false; 19 | this.stream.write = this.hookWithCast(this.stream.write.bind(this.stream)); 20 | } 21 | hook(): void { 22 | this.hooked = true; 23 | } 24 | unhook(): void { 25 | this.hooked = false; 26 | } 27 | delete(): void { 28 | this.stream.write = this.oldWrite; 29 | this.end(); 30 | } 31 | 32 | private hookWithCastOv1( 33 | fn: WriteFunc, 34 | arg1: string | Buffer | Uint8Array, 35 | arg2: BufferEncoding, 36 | arg3?: ErrorCallback, 37 | ): ReturnType { 38 | if (this.hooked) { 39 | this.write(arg1, arg2, arg3); 40 | } 41 | return fn(arg1, arg2, arg3); 42 | } 43 | 44 | private hookWithCastOv2( 45 | fn: WriteFunc, 46 | arg1: string | Buffer | Uint8Array, 47 | arg2?: ErrorCallback, 48 | ): ReturnType { 49 | const call = (fn: WriteFunc): ReturnType => (arg2 ? fn(arg1, arg2) : fn(arg1)); 50 | if (this.hooked) { 51 | call(this.write.bind(this)); 52 | } 53 | return call(fn); 54 | } 55 | 56 | private hookWithCast(fn: WriteFunc): WriteFunc { 57 | return (( 58 | arg1: string | Buffer | Uint8Array, 59 | arg2?: BufferEncoding | ErrorCallback, 60 | arg3?: ErrorCallback, 61 | ): ReturnType => { 62 | if (typeof arg2 === 'string') { 63 | return this.hookWithCastOv1(fn, arg1, arg2, arg3); 64 | } 65 | return this.hookWithCastOv2(fn, arg1, arg2); 66 | }) as WriteFunc; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | export { Server } from './server.js'; 2 | export { run } from './run.js'; 3 | -------------------------------------------------------------------------------- /src/server/profiler.ts: -------------------------------------------------------------------------------- 1 | import { WithFunction } from './server.js'; 2 | 3 | export class Profiler { 4 | private startTime?: [number, number]; 5 | constructor( 6 | private opts: { 7 | enabled: boolean; 8 | } = { enabled: false }, 9 | ) {} 10 | start(): void { 11 | if (this.opts.enabled) { 12 | this.startTime = process.hrtime(); 13 | } 14 | } 15 | report(fn?: WithFunction): string | undefined { 16 | if (this.opts.enabled && this.startTime) { 17 | const t1 = process.hrtime(this.startTime); 18 | let fnName = 'function'; 19 | if (fn && fn.hasFunction()) { 20 | const grpcFnName = fn.getFunction(); 21 | if (grpcFnName) { 22 | fnName = grpcFnName.getName(); 23 | } 24 | } 25 | return `${fnName} took: ${t1[0] * 1000 + t1[1] / 1000000}ms`; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/server/run.ts: -------------------------------------------------------------------------------- 1 | import { StreamHook } from './hook_stream.js'; 2 | 3 | const stdout = new StreamHook(process.stdout); 4 | const stderr = new StreamHook(process.stderr); 5 | 6 | import { ConsoleHook } from './hook_console.js'; 7 | 8 | const consoleHook = new ConsoleHook(console); 9 | 10 | import { Server } from './server.js'; 11 | 12 | interface StoppableServer { 13 | serve(): Promise; 14 | stop(): Promise; 15 | } 16 | 17 | interface RunOptions { 18 | maxMessageSize?: number; 19 | enableProfiling?: boolean; 20 | version?: string; 21 | } 22 | 23 | export const runPluginWith = (server: StoppableServer) => { 24 | return async (opts: RunOptions = {}): Promise => { 25 | const { version = process.version } = opts; 26 | if (process.argv.length > 2 && process.argv[2] === 'config') { 27 | process.stdout.write( 28 | JSON.stringify([ 29 | { 30 | provider: 'local', 31 | runtime: 'nodejs', 32 | }, 33 | { 34 | provider: 'local', 35 | runtime: 'nodejs-' + version.slice(1, version.indexOf('.')), 36 | }, 37 | ]), 38 | ); 39 | } else { 40 | const stopServer = (): void => { 41 | server.stop().catch((e) => console.error(e)); 42 | process.removeListener('SIGINT', stopServer); 43 | process.removeListener('SIGTERM', stopServer); 44 | }; 45 | process.on('SIGINT', stopServer); 46 | process.on('SIGTERM', stopServer); 47 | await server.serve(); 48 | } 49 | }; 50 | }; 51 | 52 | export const run = async (opts: RunOptions = {}): Promise => { 53 | try { 54 | const grpcServerOpts = { 55 | ...(opts.maxMessageSize && { 56 | 'grpc.max_send_message_length': opts.maxMessageSize, 57 | 'grpc.max_receive_message_length': opts.maxMessageSize, 58 | }), 59 | }; 60 | const serverOptions: Record = { 61 | ...opts, 62 | stdoutHook: stdout, 63 | stderrHook: stderr, 64 | consoleHook: consoleHook, 65 | grpcServerOpts, 66 | }; 67 | await runPluginWith(new Server(serverOptions))(opts); 68 | } catch (e) { 69 | stderr.unhook(); 70 | consoleHook.unhook(); 71 | console.trace(e); 72 | throw e; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import * as grpc from '@grpc/grpc-js'; 3 | import { GrpcHealthCheck, HealthCheckResponse, HealthService } from 'grpc-health-check-ts'; 4 | import { SubscriptionListenInput, SubscriptionListenEmitter } from '../api/index.js'; 5 | import { getHandler } from '../handler/index.js'; 6 | import * as messages from '../proto/driver/messages.js'; 7 | import * as driverService from '../proto/driver/driver_service.js'; 8 | import { 9 | authorize, 10 | fieldResolve, 11 | interfaceResolveType, 12 | makeProtoError, 13 | setSecrets, 14 | scalarParse, 15 | scalarSerialize, 16 | unionResolveType, 17 | subscriptionConnection, 18 | subscriptionListen, 19 | } from '../proto/driver/index.js'; 20 | import { Writable } from 'stream'; 21 | import { Profiler } from './profiler.js'; 22 | import { setSecretsEnvironment } from '../raw/set_secrets.js'; 23 | 24 | type ServerStatusResponse = Partial; 25 | type ServerErrorResponse = ServerStatusResponse & Error; 26 | 27 | function toBuffer(v: unknown): Buffer { 28 | if (Buffer.isBuffer(v)) { 29 | return v; 30 | } 31 | if (ArrayBuffer.isView(v)) { 32 | return Buffer.from(v.buffer); 33 | } 34 | return Buffer.from(`${v}`); 35 | } 36 | 37 | export interface WithFunction { 38 | hasFunction(): boolean; 39 | getFunction(): { getName: () => string } | undefined; 40 | } 41 | 42 | function isCallRequestWithFunction( 43 | v: grpc.ServerUnaryCall, 44 | ): v is grpc.ServerUnaryCall { 45 | return ( 46 | typeof v.request === 'object' && v.request !== null && 'hasFunction' in v.request && 'getFunction' in v.request 47 | ); 48 | } 49 | 50 | export interface ServerOptions { 51 | bindAddress?: string; 52 | enableProfiling?: boolean; 53 | pluginMode?: boolean; 54 | rootCerts?: string; 55 | privateKey?: string; 56 | certChain?: string; 57 | checkClientCertificate?: boolean; 58 | grpcServerOpts?: Record; 59 | server?: GRPCServer; 60 | stdoutHook?: StreamHook; 61 | stderrHook?: StreamHook; 62 | consoleHook?: ConsoleHook; 63 | } 64 | 65 | export interface GRPCServer { 66 | start: typeof grpc.Server.prototype.start; 67 | addService: typeof grpc.Server.prototype.addService; 68 | tryShutdown: typeof grpc.Server.prototype.tryShutdown; 69 | bind: typeof grpc.Server.prototype.bind; 70 | bindAsync: typeof grpc.Server.prototype.bindAsync; 71 | } 72 | 73 | interface WithError { 74 | setError: (err: messages.Error) => void; 75 | } 76 | 77 | function doError(e: unknown, v: WithError): void { 78 | v.setError(makeProtoError(e)); 79 | } 80 | 81 | interface StreamHook { 82 | on(event: 'data', listener: (chunk: unknown) => void): StreamHook; 83 | on(event: 'end', listener: () => void): StreamHook; 84 | removeListener(event: 'data', listener: (chunk: unknown) => void): StreamHook; 85 | hook(): void; 86 | unhook(): void; 87 | } 88 | 89 | interface ConsoleHook { 90 | hook(): void; 91 | unhook(): void; 92 | } 93 | 94 | export class Server { 95 | private server: GRPCServer; 96 | private stdoutStreams: Writable[]; 97 | private stderrStreams: Writable[]; 98 | private stdoutHook?: StreamHook; 99 | private stderrHook?: StreamHook; 100 | private consoleHook?: ConsoleHook; 101 | constructor(private serverOpts: ServerOptions = {}) { 102 | const healthCheckStatusMap = { 103 | plugin: HealthCheckResponse.ServingStatus.SERVING, 104 | }; 105 | this.server = serverOpts.server || new grpc.Server(this.serverOpts.grpcServerOpts); 106 | const grpcHealthCheck = new GrpcHealthCheck(healthCheckStatusMap); 107 | this.server.addService(HealthService, grpcHealthCheck.server); 108 | this.server.addService(driverService.DriverService, { 109 | authorize: this.wrapUnaryCall(this, this.unaryCallHandler(messages.AuthorizeResponse, authorize)), 110 | fieldResolve: this.wrapUnaryCall(this, this.unaryCallHandler(messages.FieldResolveResponse, fieldResolve)), 111 | interfaceResolveType: this.wrapUnaryCall( 112 | this, 113 | this.unaryCallHandler(messages.InterfaceResolveTypeResponse, interfaceResolveType), 114 | ), 115 | scalarParse: this.wrapUnaryCall(this, this.unaryCallHandler(messages.ScalarParseResponse, scalarParse)), 116 | scalarSerialize: this.wrapUnaryCall( 117 | this, 118 | this.unaryCallHandler(messages.ScalarSerializeResponse, scalarSerialize), 119 | ), 120 | setSecrets: this.wrapUnaryCall(this, this.setSecrets), 121 | stderr: this.wrapServerStreamingCall(this, this.stderr), 122 | stdout: this.wrapServerStreamingCall(this, this.stdout), 123 | stream: (srv: grpc.ServerWritableStream) => this.stream(srv), 124 | unionResolveType: this.wrapUnaryCall( 125 | this, 126 | this.unaryCallHandler(messages.UnionResolveTypeResponse, unionResolveType), 127 | ), 128 | subscriptionConnection: this.wrapUnaryCall(this, this.subscriptionConnection), 129 | subscriptionListen: ( 130 | srv: grpc.ServerWritableStream, 131 | ) => this.subscriptionListen(srv), 132 | }); 133 | this.stdoutStreams = []; 134 | this.stderrStreams = []; 135 | 136 | // stdio hooks 137 | this.stdoutHook = serverOpts.stdoutHook; 138 | this.stderrHook = serverOpts.stderrHook; 139 | this.consoleHook = serverOpts.consoleHook; 140 | this.addListener((data: unknown): void => { 141 | this.writeToGRPCStdout(toBuffer(data)); 142 | }, this.stdoutHook); 143 | this.addListener((data: unknown): void => { 144 | this.writeToGRPCStderr(toBuffer(data)); 145 | }, this.stderrHook); 146 | } 147 | 148 | private addListener(listener: (data: unknown) => void, hook?: StreamHook): void { 149 | if (!hook) { 150 | return; 151 | } 152 | hook.on('data', listener); 153 | hook.on('end', () => { 154 | hook.removeListener('data', listener); 155 | }); 156 | } 157 | 158 | private writeToGRPC(data: Buffer, streams: Writable[]): Writable[] { 159 | const msg = new messages.ByteStream(); 160 | msg.setData(Uint8Array.from(data)); 161 | streams.forEach((v) => { 162 | v.write(msg, (e) => { 163 | if (e) { 164 | v.end(); 165 | streams = streams.filter((stream) => stream !== v); 166 | } 167 | }); 168 | }); 169 | return streams; 170 | } 171 | private writeToGRPCStdout(data: Buffer): void { 172 | this.stdoutStreams = this.writeToGRPC(data, this.stdoutStreams); 173 | } 174 | private writeToGRPCStderr(data: Buffer): void { 175 | this.stderrStreams = this.writeToGRPC(data, this.stderrStreams); 176 | } 177 | 178 | private hookIO(): void { 179 | if (this.stdoutHook) { 180 | this.stdoutHook.hook(); 181 | } 182 | if (this.stderrHook) { 183 | this.stderrHook.hook(); 184 | } 185 | if (this.consoleHook) { 186 | this.consoleHook.hook(); 187 | } 188 | } 189 | 190 | private unhookIO(): void { 191 | if (this.stdoutHook) { 192 | this.stdoutHook.unhook(); 193 | } 194 | if (this.stderrHook) { 195 | this.stderrHook.unhook(); 196 | } 197 | if (this.consoleHook) { 198 | this.consoleHook.unhook(); 199 | } 200 | } 201 | 202 | private executeUserHandler( 203 | call: grpc.ServerUnaryCall, 204 | callback: grpc.sendUnaryData, 205 | responseCtor: { 206 | new (): U; 207 | }, 208 | fn: (req: T, handler: (x: V) => Promise) => Promise, 209 | ): void { 210 | const f = async () => { 211 | try { 212 | const handler = await getHandler(call.request); 213 | const response = await fn(call.request, handler); 214 | callback(null, response); 215 | } catch (e) { 216 | const response = new responseCtor(); 217 | doError(e, response); 218 | callback(null, response); 219 | } 220 | }; 221 | f().catch((e) => console.error(e)); 222 | } 223 | 224 | private unaryCallHandler( 225 | responseCtor: { 226 | new (): U; 227 | }, 228 | fn: (req: T, handler: (x: V) => Promise) => Promise, 229 | ): grpc.handleUnaryCall { 230 | return (call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData): void => 231 | this.executeUserHandler(call, callback, responseCtor, fn); 232 | } 233 | 234 | private setSecrets( 235 | call: grpc.ServerUnaryCall, 236 | callback: grpc.sendUnaryData, 237 | ): void { 238 | setSecrets(call.request, setSecretsEnvironment) 239 | .then((v) => callback(null, v)) 240 | .catch((e) => console.error(e)); 241 | } 242 | 243 | private stream(srv: grpc.ServerWritableStream): void { 244 | console.error(`Requested stream with function ${srv.request.getFunction()?.getName()}`); 245 | throw new Error('Data streaming is not yet implemented'); 246 | } 247 | 248 | private subscriptionConnection( 249 | call: grpc.ServerUnaryCall, 250 | callback: grpc.sendUnaryData, 251 | ): void { 252 | this.executeUserHandler(call, callback, messages.SubscriptionConnectionResponse, subscriptionConnection); 253 | } 254 | 255 | private subscriptionListen( 256 | srv: grpc.ServerWritableStream, 257 | ): void { 258 | const f = async () => { 259 | try { 260 | const handler = await getHandler(srv.request); 261 | await subscriptionListen(srv, handler); 262 | } catch (e) { 263 | console.error(e); 264 | } 265 | }; 266 | f(); 267 | } 268 | public start(): void { 269 | try { 270 | this.hookIO(); 271 | this.server.start(); 272 | } catch (e) { 273 | console.error(e); 274 | } 275 | } 276 | 277 | public credentials(pluginMode: boolean): grpc.ServerCredentials { 278 | const { rootCerts, privateKey, certChain, checkClientCertificate } = this.serverOpts; 279 | if (pluginMode || (!rootCerts && !privateKey && !certChain)) { 280 | return grpc.ServerCredentials.createInsecure(); 281 | } 282 | if (!rootCerts || !privateKey || !certChain) { 283 | // refuse to setup server with partial TLS setup 284 | throw new Error('TLS certificate chain defined partially'); 285 | } 286 | const rootCert = readFileSync(rootCerts); 287 | const certs: grpc.KeyCertPair = { 288 | cert_chain: readFileSync(certChain), 289 | private_key: readFileSync(privateKey), 290 | }; 291 | return grpc.ServerCredentials.createSsl(rootCert, [certs], checkClientCertificate); 292 | } 293 | 294 | public serve(): Promise { 295 | const { bindAddress = '0.0.0.0:1234', pluginMode = true } = this.serverOpts; 296 | const creds: grpc.ServerCredentials = this.credentials(pluginMode); 297 | return new Promise((resolve, reject) => { 298 | this.server.bindAsync(bindAddress, creds, (err, port) => { 299 | if (err) { 300 | reject(err); 301 | return; 302 | } 303 | if (pluginMode) { 304 | console.log(`1|1|tcp|127.0.0.1:${port}|grpc`); 305 | } 306 | this.start(); 307 | resolve(); 308 | }); 309 | }); 310 | } 311 | 312 | public stop(): Promise { 313 | this.unhookIO(); 314 | return new Promise((resolve, reject) => this.server.tryShutdown((err) => (err ? reject(err) : resolve()))); 315 | } 316 | private profileFunction( 317 | call: grpc.ServerUnaryCall, 318 | callback: grpc.sendUnaryData, 319 | boundFn: grpc.handleUnaryCall, 320 | ): void { 321 | const profiler = new Profiler({ enabled: !!this.serverOpts.enableProfiling }); 322 | profiler.start(); 323 | boundFn( 324 | call, 325 | ( 326 | error: ServerErrorResponse | ServerStatusResponse | null, 327 | value?: U | null, 328 | trailer?: grpc.Metadata, 329 | flags?: number, 330 | ): void => { 331 | callback(error, value, trailer, flags); 332 | const report = profiler.report(call && call.request); 333 | if (report) { 334 | console.error(report); 335 | } 336 | }, 337 | ); 338 | } 339 | private wrapUnaryCall(srv: Server, fn: grpc.handleUnaryCall): grpc.handleUnaryCall { 340 | const boundFn = fn.bind(srv); 341 | return (call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData): void => { 342 | if (isCallRequestWithFunction(call)) { 343 | this.profileFunction(call, callback, boundFn); 344 | } else { 345 | boundFn(call, callback); 346 | } 347 | }; 348 | } 349 | private wrapServerStreamingCall( 350 | srv: Server, 351 | fn: grpc.handleServerStreamingCall, 352 | ): grpc.handleServerStreamingCall { 353 | const boundFn = fn.bind(srv); 354 | return (call: grpc.ServerWritableStream) => boundFn(call); 355 | } 356 | 357 | private stdout(call: Writable): void { 358 | this.stdoutStreams.push(call); 359 | } 360 | 361 | private stderr(call: Writable): void { 362 | this.stderrStreams.push(call); 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/stucco/cmd.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\cmd.js" %* 4 | -------------------------------------------------------------------------------- /src/stucco/cmd.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { stucco } from './run.js'; 3 | import { spawn } from 'child_process'; 4 | (async (): Promise => { 5 | const bin = await stucco(); 6 | const args = process.argv.length > 2 ? process.argv.slice(2) : ['local', 'start']; 7 | const child = spawn(bin, args, { stdio: 'inherit' }); 8 | const kill = () => child.kill(); 9 | process.on('SIGTERM', kill); 10 | process.on('SIGINT', kill); 11 | })().catch((e) => console.error(e)); 12 | -------------------------------------------------------------------------------- /src/stucco/dirname.cts: -------------------------------------------------------------------------------- 1 | module.exports = { __dirname } 2 | -------------------------------------------------------------------------------- /src/stucco/run.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { version } from './version.js'; 4 | import { platform, arch } from 'os'; 5 | import { promises, createWriteStream } from 'fs'; 6 | import { join, dirname } from 'path'; 7 | import { fileURLToPath } from 'url'; 8 | import { get } from 'https'; 9 | import { retry } from '../util/util.js'; 10 | import { IncomingMessage } from 'http'; 11 | import binVersionCheck from 'bin-version-check'; 12 | const { chmod, rm, stat, mkdir } = promises; 13 | 14 | const __dirname = dirname(fileURLToPath(import.meta.url)); 15 | const base = `https://stucco-release.fra1.cdn.digitaloceanspaces.com/${version}`; 16 | const dest = join(__dirname, 'vendor'); 17 | const filename = join(dest, platform() === 'win32' ? 'stucco.exe' : 'stucco'); 18 | const src = () => { 19 | const src: Partial>> = { 20 | darwin: { 21 | x64: `${base}/darwin/amd64/stucco`, 22 | arm64: `${base}/darwin/arm64/stucco`, 23 | }, 24 | linux: { 25 | x32: `${base}/linux/386/stucco`, 26 | x64: `${base}/linux/amd64/stucco`, 27 | arm64: `${base}/linux/arm64/stucco`, 28 | }, 29 | win32: { 30 | x32: `${base}/windows/386/stucco.exe`, 31 | x64: `${base}/windows/amd64/stucco.exe`, 32 | arm64: `${base}/windows/arm64/stucco.exe`, 33 | }, 34 | }; 35 | const plat = src[platform()] || {}; 36 | return plat[arch()]; 37 | }; 38 | 39 | const fetchBin = async () => { 40 | const res = await new Promise((resolve, reject) => { 41 | const req = get(src(), resolve); 42 | req.on('error', reject); 43 | }); 44 | const { statusCode = 0 } = res; 45 | if (statusCode < 200 || statusCode > 299) { 46 | throw new Error('Did not recieve 200 class respopnse'); 47 | } 48 | await mkdir(dirname(filename), { recursive: true }); 49 | await new Promise((resolve, reject) => { 50 | const bin = createWriteStream(filename, { autoClose: true }); 51 | bin.on('error', reject); 52 | bin.on('close', resolve); 53 | bin.on('open', () => { 54 | res.pipe(bin); 55 | res.on('error', (e) => { 56 | reject(e); 57 | bin.close(); 58 | }); 59 | res.on('end', () => bin.close()); 60 | }); 61 | }); 62 | if (platform() != 'win32') { 63 | await chmod(filename, '700'); 64 | } 65 | }; 66 | 67 | async function fetchCheckBin() { 68 | try { 69 | await stat(filename); 70 | } catch { 71 | await fetchBin().catch((e) => { 72 | console.log(e); 73 | throw e; 74 | }); 75 | } 76 | await binVersionCheck(filename, `~${version}`, { args: ['version'] }); 77 | } 78 | 79 | async function tryRemove(e: unknown): Promise { 80 | await rm(dest, { recursive: true }); 81 | throw e; 82 | } 83 | 84 | export async function stucco(): Promise { 85 | await retry(() => fetchCheckBin().catch(tryRemove), { retries: 2, minTimeout: 10 }); 86 | return filename; 87 | } 88 | -------------------------------------------------------------------------------- /src/stucco/version.ts: -------------------------------------------------------------------------------- 1 | export const version = 'v0.10.25'; 2 | -------------------------------------------------------------------------------- /src/typings/bin-check.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'bin-check' { 2 | function binCheck(path: string): Promise; 3 | export = binCheck; 4 | } 5 | -------------------------------------------------------------------------------- /src/typings/bin-version-check.ts: -------------------------------------------------------------------------------- 1 | declare module 'bin-version-check' { 2 | function binVersionCheck(path: string, version: string, options: { args: string[] }): Promise; 3 | export = binVersionCheck; 4 | } 5 | -------------------------------------------------------------------------------- /src/typings/bin-wrapper.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'bin-wrapper' { 2 | interface Options { 3 | skipCheck?: boolean; 4 | } 5 | class BinWrapper { 6 | constructor(options?: Options); 7 | src(arg1: string, arg2?: string, arg3?: string): BinWrapper; 8 | src(): string; 9 | dest(arg1: string): BinWrapper; 10 | dest(): string; 11 | use(arg1: string): BinWrapper; 12 | use(): string; 13 | run(args?: string[]): Promise; 14 | path(): string; 15 | version(arg1: string): BinWrapper; 16 | version(): string; 17 | } 18 | export = BinWrapper; 19 | } 20 | -------------------------------------------------------------------------------- /src/typings/yargs.d.ts: -------------------------------------------------------------------------------- 1 | import 'yargs'; 2 | 3 | declare module 'yargs' { 4 | interface Argv { 5 | command(module: ReadonlyArray>): Argv; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/util/log.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | DEBUG = 0, 3 | INFO = 1, 4 | WARN = 2, 5 | ERROR = 3, 6 | FATAL = 4, 7 | } 8 | let logLevel = LogLevel.INFO; 9 | 10 | export function setLogLevel(lv: LogLevel): void { 11 | logLevel = lv; 12 | } 13 | 14 | function log(lv: LogLevel, fn: (...data: unknown[]) => void, ...data: unknown[]) { 15 | if (logLevel >= lv) { 16 | fn(...data); 17 | } 18 | } 19 | 20 | const cdebug = (...data: unknown[]) => console.debug(...data); 21 | export const debug = (...data: unknown[]): void => log(LogLevel.DEBUG, cdebug, ...data); 22 | 23 | const cinfo = (...data: unknown[]) => console.info(...data); 24 | export const info = (...data: unknown[]): void => log(LogLevel.INFO, cinfo, ...data); 25 | 26 | const cwarn = (...data: unknown[]) => console.warn(...data); 27 | export const warn = (...data: unknown[]): void => log(LogLevel.WARN, cwarn, ...data); 28 | 29 | const cerror = (...data: unknown[]) => console.error(...data); 30 | export const error = (...data: unknown[]): void => log(LogLevel.ERROR, cerror, ...data); 31 | 32 | export function fatal(...data: unknown[]): void { 33 | log(LogLevel.FATAL, cerror, ...data); 34 | process.exit(1); 35 | } 36 | -------------------------------------------------------------------------------- /src/util/util.ts: -------------------------------------------------------------------------------- 1 | import { operation, OperationOptions } from 'retry'; 2 | 3 | export function notUndefined(v: T | undefined): v is T { 4 | return v !== undefined; 5 | } 6 | 7 | export async function retry(f: () => Promise | T, opts?: OperationOptions): Promise { 8 | const op = operation( 9 | opts || { 10 | retries: 5, 11 | factor: 3, 12 | minTimeout: 1 * 1000, 13 | maxTimeout: 60 * 1000, 14 | randomize: true, 15 | }, 16 | ); 17 | return new Promise((resolve, reject) => 18 | op.attempt(async () => { 19 | try { 20 | resolve(await f()); 21 | } catch (e) { 22 | const err = e === undefined || e instanceof Error ? e : new Error('unknown error'); 23 | if (!op.retry(err)) { 24 | reject(err); 25 | } 26 | } 27 | }), 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /stucco.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphql-editor/stucco-js/b992a6a615ce37e8f6a5fd1ec8745da0116babfb/stucco.jpg -------------------------------------------------------------------------------- /tests/api/api.test.ts: -------------------------------------------------------------------------------- 1 | import { isListTypeRef, isNamedTypeRef, isNonNullTypeRef } from '../../src/api/index.js'; 2 | 3 | describe('handler assertion', () => { 4 | it('is named type', () => { 5 | expect(isNamedTypeRef({ name: 'name' })).toBeTruthy(); 6 | expect(isNamedTypeRef({ list: { name: 'name' } })).toBeFalsy(); 7 | expect(isNamedTypeRef({ nonNull: { name: 'name' } })).toBeFalsy(); 8 | }); 9 | it('is non null type', () => { 10 | expect(isNonNullTypeRef({ name: 'name' })).toBeFalsy(); 11 | expect(isNonNullTypeRef({ list: { name: 'name' } })).toBeFalsy(); 12 | expect(isNonNullTypeRef({ nonNull: { name: 'name' } })).toBeTruthy(); 13 | }); 14 | it('is list type', () => { 15 | expect(isListTypeRef({ name: 'name' })).toBeFalsy(); 16 | expect(isListTypeRef({ list: { name: 'name' } })).toBeTruthy(); 17 | expect(isListTypeRef({ nonNull: { name: 'name' } })).toBeFalsy(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/handler/index.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { getHandler } from '../../src/handler/index.js'; 3 | import { readdir, mkdir, copyFile, rm } from 'fs/promises'; 4 | 5 | interface WithFunction { 6 | hasFunction(): boolean; 7 | getFunction(): { getName: () => string } | undefined; 8 | } 9 | 10 | async function copyDir(src: string, dest: string) { 11 | await mkdir(dest, { recursive: true }); 12 | const entries = await readdir(src, { withFileTypes: true }); 13 | 14 | await Promise.all( 15 | entries.map(async (entry) => { 16 | const srcPath = join(src, entry.name); 17 | const destPath = join(dest, entry.name); 18 | entry.isDirectory() ? await copyDir(srcPath, destPath) : await copyFile(srcPath, destPath); 19 | }), 20 | ); 21 | } 22 | 23 | describe('handler', () => { 24 | const mockWithFunction = (name: string): WithFunction => { 25 | return { 26 | hasFunction: (): boolean => true, 27 | getFunction: (): { getName: () => string } | undefined => ({ 28 | getName: (): string => name, 29 | }), 30 | }; 31 | }; 32 | const cwd = process.cwd(); 33 | beforeAll(async () => { 34 | const nm = join(cwd, 'tests', 'handler', 'testdata', 'node_modules'); 35 | const testPkgs = await readdir(nm); 36 | await Promise.all(testPkgs.map((pkg) => copyDir(join(nm, pkg), join(cwd, 'node_modules', pkg)))); 37 | }); 38 | afterAll(async () => { 39 | const nm = join(cwd, 'tests', 'handler', 'testdata', 'node_modules'); 40 | const testPkgs = await readdir(nm); 41 | await Promise.all(testPkgs.map((pkg) => rm(join(cwd, 'node_modules', pkg), { recursive: true }))); 42 | }); 43 | beforeEach(() => { 44 | process.chdir(join(cwd, 'tests', 'handler', 'testdata')); 45 | }); 46 | afterEach(() => { 47 | process.chdir(cwd); 48 | }); 49 | it('throws when function is missing', async () => { 50 | await expect( 51 | getHandler({ hasFunction: (): boolean => false, getFunction: () => undefined }), 52 | ).rejects.toHaveProperty('message'); 53 | }); 54 | it('throws when function name is empty', async () => { 55 | await expect(getHandler(mockWithFunction(''))).rejects.toHaveProperty('message'); 56 | }); 57 | it('imports commonjs default export', async () => { 58 | const v = ((await getHandler(mockWithFunction('commonjs_default'))) as (arg: unknown) => unknown)(1); 59 | expect(v).resolves.toEqual(1); 60 | }); 61 | it('imports commonjs handler export if name empty', async () => { 62 | const v = ((await getHandler(mockWithFunction('commonjs_handler'))) as (arg: unknown) => unknown)(1); 63 | expect(v).resolves.toEqual(1); 64 | }); 65 | it('imports commonjs named function', async () => { 66 | const v = ((await getHandler(mockWithFunction('commonjs_named.fnname'))) as (arg: unknown) => unknown)(1); 67 | expect(v).resolves.toEqual(1); 68 | }); 69 | it('imports es module default function', async () => { 70 | const v = ((await getHandler(mockWithFunction('esm_default'))) as (arg: unknown) => unknown)(1); 71 | expect(v).resolves.toEqual(1); 72 | }); 73 | it('imports es handler export if name empty', async () => { 74 | const v = ((await getHandler(mockWithFunction('esm_handler'))) as (arg: unknown) => unknown)(1); 75 | expect(v).resolves.toEqual(1); 76 | }); 77 | it('imports es named function', async () => { 78 | const v = ((await getHandler(mockWithFunction('esm_named.fnname'))) as (arg: unknown) => unknown)(1); 79 | expect(v).resolves.toEqual(1); 80 | }); 81 | it('imports es dict named function', async () => { 82 | const v = ( 83 | (await getHandler(mockWithFunction('esm_dict_named@firstName.secondName.fnname'))) as (arg: unknown) => unknown 84 | )(1); 85 | expect(v).resolves.toEqual(1); 86 | }); 87 | it('imports externaltesthandleresm module', async () => { 88 | const v = ((await getHandler(mockWithFunction('externaltesthandleresm'))) as (arg: unknown) => unknown)(1); 89 | expect(v).resolves.toEqual(1); 90 | }); 91 | it('throws if function not defined', async () => { 92 | expect(getHandler(mockWithFunction('function'))).rejects.toHaveProperty('message'); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/handler/testdata/commonjs_default.cjs: -------------------------------------------------------------------------------- 1 | module.exports = (arg) => arg; 2 | -------------------------------------------------------------------------------- /tests/handler/testdata/commonjs_handler.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | handler: (arg) => arg, 3 | } 4 | 5 | -------------------------------------------------------------------------------- /tests/handler/testdata/commonjs_named.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | fnname: (arg) => arg, 3 | } 4 | 5 | -------------------------------------------------------------------------------- /tests/handler/testdata/esm_default.js: -------------------------------------------------------------------------------- 1 | export default (arg) => arg; 2 | -------------------------------------------------------------------------------- /tests/handler/testdata/esm_dict_named.js: -------------------------------------------------------------------------------- 1 | export const firstName = { 2 | secondName: { 3 | fnname: (arg) => arg, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /tests/handler/testdata/esm_handler.js: -------------------------------------------------------------------------------- 1 | export const handler = (arg) => arg; 2 | -------------------------------------------------------------------------------- /tests/handler/testdata/esm_named.js: -------------------------------------------------------------------------------- 1 | export const fnname = (arg) => arg; 2 | -------------------------------------------------------------------------------- /tests/handler/testdata/node_modules/externaltesthandleresm/index.js: -------------------------------------------------------------------------------- 1 | export const handler = (arg) => arg; 2 | -------------------------------------------------------------------------------- /tests/handler/testdata/node_modules/externaltesthandleresm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "externaltesthandleresm", 3 | "main": "index.js", 4 | "type": "module", 5 | "version": "1.0.0" 6 | } 7 | -------------------------------------------------------------------------------- /tests/proto/driver/value.test.ts: -------------------------------------------------------------------------------- 1 | import * as value from '../../../src/proto/driver/value.js'; 2 | import * as messages from '../../../src/proto/driver/messages.js'; 3 | const { 4 | nilValue, 5 | anyValue, 6 | arrValue, 7 | stringValue, 8 | intValue, 9 | uintValue, 10 | floatValue, 11 | booleanValue, 12 | objValue, 13 | variableValue, 14 | } = messages; 15 | 16 | describe('value marshaling/unmarshaling', () => { 17 | it('marshals undefined and null', () => { 18 | expect(value.valueFromAny(undefined)).toEqual(nilValue()); 19 | expect(value.valueFromAny(null)).toEqual(nilValue()); 20 | }); 21 | it('marshals bytes', () => { 22 | const expected8 = anyValue(Uint8Array.from(Buffer.from('data'))); 23 | const expected16 = anyValue(new Uint8Array(Uint16Array.from(Buffer.from('data')).buffer)); 24 | const expected32 = anyValue(new Uint8Array(Uint32Array.from(Buffer.from('data')).buffer)); 25 | expect(value.valueFromAny(Buffer.from('data'))).toEqual(expected8); 26 | expect(value.valueFromAny(Uint8Array.from(Buffer.from('data')))).toEqual(expected8); 27 | expect(value.valueFromAny(Uint16Array.from(Buffer.from('data')))).toEqual(expected16); 28 | expect(value.valueFromAny(Uint32Array.from(Buffer.from('data')))).toEqual(expected32); 29 | }); 30 | it('marshals array', () => { 31 | expect(value.valueFromAny(['a', 'b'])).toEqual(arrValue([stringValue('a'), stringValue('b')])); 32 | }); 33 | it('marshals integral', () => { 34 | expect(value.valueFromAny(1)).toEqual(intValue(1)); 35 | }); 36 | it('marshals floating point', () => { 37 | expect(value.valueFromAny(1.1)).toEqual(floatValue(1.1)); 38 | }); 39 | it('marshals boolean', () => { 40 | expect(value.valueFromAny(true)).toEqual(booleanValue(true)); 41 | }); 42 | it('marshals object', () => { 43 | expect( 44 | value.valueFromAny({ 45 | prop: 'value', 46 | }), 47 | ).toEqual(objValue({ prop: stringValue('value') })); 48 | }); 49 | it('handles undefined and bad value', () => { 50 | expect(value.getFromValue(undefined)).toBeUndefined(); 51 | expect(value.getFromValue(new messages.Value())).toBeUndefined(); 52 | }); 53 | it('unmarshals nil', () => { 54 | expect(value.getFromValue(nilValue())).toEqual(null); 55 | }); 56 | it('unmarshals integral', () => { 57 | expect(value.getFromValue(intValue(1))).toEqual(1); 58 | }); 59 | it('unmarshals unsigned integral', () => { 60 | expect(value.getFromValue(uintValue(1))).toEqual(1); 61 | }); 62 | it('unmarshals floating point', () => { 63 | expect(value.getFromValue(floatValue(1.1))).toEqual(1.1); 64 | }); 65 | it('unmarshals string', () => { 66 | expect(value.getFromValue(stringValue('string'))).toEqual('string'); 67 | }); 68 | it('unmarshals boolean', () => { 69 | expect(value.getFromValue(booleanValue(true))).toEqual(true); 70 | }); 71 | it('unmarshals object', () => { 72 | expect( 73 | value.getFromValue( 74 | objValue({ 75 | prop: stringValue('value'), 76 | }), 77 | ), 78 | ).toEqual({ prop: 'value' }); 79 | }); 80 | it('unmarshals array', () => { 81 | expect(value.getFromValue(arrValue([stringValue('value')]))).toEqual(['value']); 82 | }); 83 | it('unmarshals bytes', () => { 84 | expect(value.getFromValue(anyValue(Uint8Array.from(Buffer.from('data'))))).toEqual( 85 | Uint8Array.from(Buffer.from('data')), 86 | ); 87 | }); 88 | it('unmarshals variable to value', () => { 89 | expect( 90 | value.getFromValue(variableValue('variable'), { 91 | variable: stringValue('value'), 92 | }), 93 | ).toEqual('value'); 94 | expect(value.getFromValue(variableValue('variable'))).toEqual(undefined); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /tests/raw/field_resolve.test.ts: -------------------------------------------------------------------------------- 1 | import * as messages from '../../src/proto/driver/messages'; 2 | import { fieldResolveHandler } from '../../src/raw/field_resolve.js'; 3 | import { join } from 'path'; 4 | describe('raw field resolve handler', () => { 5 | const cwd = process.cwd(); 6 | beforeEach(() => { 7 | process.chdir(join(cwd, 'tests', 'raw', 'testdata')); 8 | }); 9 | afterEach(() => { 10 | process.chdir(cwd); 11 | }); 12 | it('checks content type', async () => { 13 | const data: Array<{ 14 | contentType: string; 15 | expectedErrorMessage: string; 16 | assertion: (expected: Uint8Array, actual: Uint8Array) => void; 17 | }> = [ 18 | { 19 | contentType: 'application/x-protobuf', 20 | expectedErrorMessage: '"application/x-protobuf" is not a valid content-type', 21 | assertion: (expected, actual: Uint8Array): void => { 22 | expect(actual).toEqual(expected); 23 | }, 24 | }, 25 | { 26 | contentType: 'application/x-protobuf;message=FieldResolveRequest', 27 | expectedErrorMessage: '"application/x-protobuf;message=FieldResolveRequest" is not a valid content-type', 28 | assertion: (expected, actual: Uint8Array): void => { 29 | expect(actual).not.toEqual(expected); 30 | }, 31 | }, 32 | ]; 33 | await Promise.all( 34 | data.map(async (tc) => { 35 | const expectedResponse = new messages.FieldResolveResponse(); 36 | const responseError = new messages.Error(); 37 | responseError.setMsg(tc.expectedErrorMessage); 38 | expectedResponse.setError(responseError); 39 | tc.assertion(expectedResponse.serializeBinary(), await fieldResolveHandler(tc.contentType, new Uint8Array())); 40 | return Promise.resolve(); 41 | }), 42 | ); 43 | }); 44 | it('calls handler', async () => { 45 | const req = new messages.FieldResolveRequest(); 46 | req.setInfo(new messages.FieldResolveInfo()); 47 | const func = new messages.Function(); 48 | func.setName('field_nil_resolve'); 49 | req.setFunction(func); 50 | const expected = new messages.FieldResolveResponse(); 51 | const nilObject = new messages.Value(); 52 | nilObject.setNil(true); 53 | expected.setResponse(nilObject); 54 | const response = await fieldResolveHandler( 55 | 'application/x-protobuf;message=FieldResolveRequest', 56 | req.serializeBinary(), 57 | ); 58 | expect(response).toEqual(expected.serializeBinary()); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/raw/interface_resolve_type.test.ts: -------------------------------------------------------------------------------- 1 | import * as messages from '../../src/proto/driver/messages.js'; 2 | import { interfaceResolveTypeHandler } from '../../src/raw/interface_resolve_type.js'; 3 | import { join } from 'path'; 4 | describe('raw interface resolve type handler', () => { 5 | const cwd = process.cwd(); 6 | beforeEach(() => { 7 | process.chdir(join(cwd, 'tests', 'raw', 'testdata')); 8 | }); 9 | afterEach(() => { 10 | process.chdir(cwd); 11 | }); 12 | it('checks content type', async () => { 13 | const data: Array<{ 14 | contentType: string; 15 | expectedErrorMessage: string; 16 | assertion: (expected: Uint8Array, actual: Uint8Array) => void; 17 | }> = [ 18 | { 19 | contentType: 'application/x-protobuf', 20 | expectedErrorMessage: '"application/x-protobuf" is not a valid content-type', 21 | assertion: (expected, actual: Uint8Array): void => { 22 | expect(actual).toEqual(expected); 23 | }, 24 | }, 25 | { 26 | contentType: 'application/x-protobuf;message=InterfaceResolveTypeRequest', 27 | expectedErrorMessage: 28 | '"application/x-protobuf;message=InterfaceResolveTypeRequest" is not a valid content-type', 29 | assertion: (expected, actual: Uint8Array): void => { 30 | expect(actual).not.toEqual(expected); 31 | }, 32 | }, 33 | ]; 34 | await Promise.all( 35 | data.map(async (tc) => { 36 | const expectedResponse = new messages.InterfaceResolveTypeResponse(); 37 | const responseError = new messages.Error(); 38 | responseError.setMsg(tc.expectedErrorMessage); 39 | expectedResponse.setError(responseError); 40 | tc.assertion( 41 | expectedResponse.serializeBinary(), 42 | await interfaceResolveTypeHandler(tc.contentType, new Uint8Array()), 43 | ); 44 | return Promise.resolve(); 45 | }), 46 | ); 47 | }); 48 | it('calls handler', async () => { 49 | const req = new messages.InterfaceResolveTypeRequest(); 50 | req.setInfo(new messages.InterfaceResolveTypeInfo()); 51 | const func = new messages.Function(); 52 | func.setName('interface_handler'); 53 | req.setFunction(func); 54 | const expected = new messages.InterfaceResolveTypeResponse(); 55 | const typeRef = new messages.TypeRef(); 56 | typeRef.setName('SomeType'); 57 | expected.setType(typeRef); 58 | const response = await interfaceResolveTypeHandler( 59 | 'application/x-protobuf;message=InterfaceResolveTypeRequest', 60 | req.serializeBinary(), 61 | ); 62 | expect(response).toEqual(expected.serializeBinary()); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/raw/message.test.ts: -------------------------------------------------------------------------------- 1 | import { parseMIME, getMessageType, MessageType, messageTypeToMime } from '../../src/raw/message.js'; 2 | 3 | describe('mime', () => { 4 | it('parsed', () => { 5 | const data: Array<{ 6 | mime: string; 7 | expected?: ReturnType; 8 | }> = [ 9 | { 10 | mime: '', 11 | }, 12 | { 13 | mime: 'application/x-protobuf', 14 | expected: { 15 | mimeType: 'application/x-protobuf', 16 | params: {}, 17 | }, 18 | }, 19 | { 20 | mime: 'application/x-protobuf;message=SomeParam', 21 | expected: { 22 | mimeType: 'application/x-protobuf', 23 | params: { 24 | message: 'SomeParam', 25 | }, 26 | }, 27 | }, 28 | { 29 | mime: ' application/x-protobuf; message=SomeParam ', 30 | expected: { 31 | mimeType: 'application/x-protobuf', 32 | params: { 33 | message: 'SomeParam', 34 | }, 35 | }, 36 | }, 37 | { 38 | mime: 'application/x-protobuf;message=SomeParam;someparam=SomeParam2', 39 | expected: { 40 | mimeType: 'application/x-protobuf', 41 | params: { 42 | message: 'SomeParam', 43 | someparam: 'SomeParam2', 44 | }, 45 | }, 46 | }, 47 | { 48 | mime: 'application/x-protobuf;message', 49 | expected: { 50 | mimeType: 'application/x-protobuf', 51 | params: { 52 | message: undefined, 53 | }, 54 | }, 55 | }, 56 | ]; 57 | data.forEach((tc) => { 58 | expect(parseMIME(tc.mime)).toEqual(tc.expected); 59 | }); 60 | }); 61 | }); 62 | 63 | describe('message type', () => { 64 | describe('from mime', () => { 65 | it('returns falsy for bad mime', () => { 66 | expect(getMessageType({ mimeType: 'application/json', params: {} })).toBeFalsy(); 67 | }); 68 | it('returns falsy for bad message', () => { 69 | expect(getMessageType({ mimeType: 'application/x-protobuf', params: {} })).toBeFalsy(); 70 | }); 71 | const mimeFor = ( 72 | message: string, 73 | ): { 74 | mimeType: string; 75 | params: { [k: string]: string }; 76 | } => { 77 | return { 78 | mimeType: 'application/x-protobuf', 79 | params: { message }, 80 | }; 81 | }; 82 | it('returns correct enum for FieldResolveRequest', () => { 83 | expect(getMessageType(mimeFor('FieldResolveRequest'))).toEqual(MessageType.FIELD_RESOLVE_REQUEST); 84 | }); 85 | it('returns correct enum for FieldResolveResponse', () => { 86 | expect(getMessageType(mimeFor('FieldResolveResponse'))).toEqual(MessageType.FIELD_RESOLVE_RESPONSE); 87 | }); 88 | it('returns correct enum for InterfaceResolveTypeRequest', () => { 89 | expect(getMessageType(mimeFor('InterfaceResolveTypeRequest'))).toEqual( 90 | MessageType.INTERFACE_RESOLVE_TYPE_REQUEST, 91 | ); 92 | }); 93 | it('returns correct enum for InterfaceResolveTypeResponse', () => { 94 | expect(getMessageType(mimeFor('InterfaceResolveTypeResponse'))).toEqual( 95 | MessageType.INTERFACE_RESOLVE_TYPE_RESPONSE, 96 | ); 97 | }); 98 | it('returns correct enum for SetSecretsRequest', () => { 99 | expect(getMessageType(mimeFor('SetSecretsRequest'))).toEqual(MessageType.SET_SECRETS_REQUEST); 100 | }); 101 | it('returns correct enum for SetSecretsResponse', () => { 102 | expect(getMessageType(mimeFor('SetSecretsResponse'))).toEqual(MessageType.SET_SECRETS_RESPONSE); 103 | }); 104 | it('returns correct enum for ScalarParseRequest', () => { 105 | expect(getMessageType(mimeFor('ScalarParseRequest'))).toEqual(MessageType.SCALAR_PARSE_REQUEST); 106 | }); 107 | it('returns correct enum for ScalarParseResponse', () => { 108 | expect(getMessageType(mimeFor('ScalarParseResponse'))).toEqual(MessageType.SCALAR_PARSE_RESPONSE); 109 | }); 110 | it('returns correct enum for ScalarSerializeRequest', () => { 111 | expect(getMessageType(mimeFor('ScalarSerializeRequest'))).toEqual(MessageType.SCALAR_SERIALIZE_REQUEST); 112 | }); 113 | it('returns correct enum for ScalarSerializeResponse', () => { 114 | expect(getMessageType(mimeFor('ScalarSerializeResponse'))).toEqual(MessageType.SCALAR_SERIALIZE_RESPONSE); 115 | }); 116 | it('returns correct enum for UnionResolveTypeRequest', () => { 117 | expect(getMessageType(mimeFor('UnionResolveTypeRequest'))).toEqual(MessageType.UNION_RESOLVE_TYPE_REQUEST); 118 | }); 119 | it('returns correct enum for UnionResolveTypeResponse', () => { 120 | expect(getMessageType(mimeFor('UnionResolveTypeResponse'))).toEqual(MessageType.UNION_RESOLVE_TYPE_RESPONSE); 121 | }); 122 | }); 123 | describe('to mime', () => { 124 | it('FieldResolveRequest message type', () => { 125 | expect(messageTypeToMime(MessageType.FIELD_RESOLVE_REQUEST)).toEqual( 126 | 'application/x-protobuf;message=fieldresolverequest', 127 | ); 128 | }); 129 | it('FieldResolveResponse message type', () => { 130 | expect(messageTypeToMime(MessageType.FIELD_RESOLVE_RESPONSE)).toEqual( 131 | 'application/x-protobuf;message=fieldresolveresponse', 132 | ); 133 | }); 134 | it('InterfaceResolveTypeRequest message type', () => { 135 | expect(messageTypeToMime(MessageType.INTERFACE_RESOLVE_TYPE_REQUEST)).toEqual( 136 | 'application/x-protobuf;message=interfaceresolvetyperequest', 137 | ); 138 | }); 139 | it('InterfaceResolveTypeResponse message type', () => { 140 | expect(messageTypeToMime(MessageType.INTERFACE_RESOLVE_TYPE_RESPONSE)).toEqual( 141 | 'application/x-protobuf;message=interfaceresolvetyperesponse', 142 | ); 143 | }); 144 | it('SetSecretsRequest message type', () => { 145 | expect(messageTypeToMime(MessageType.SET_SECRETS_REQUEST)).toEqual( 146 | 'application/x-protobuf;message=setsecretsrequest', 147 | ); 148 | }); 149 | it('SetSecretsResponse message type', () => { 150 | expect(messageTypeToMime(MessageType.SET_SECRETS_RESPONSE)).toEqual( 151 | 'application/x-protobuf;message=setsecretsresponse', 152 | ); 153 | }); 154 | it('ScalarParseRequest message type', () => { 155 | expect(messageTypeToMime(MessageType.SCALAR_PARSE_REQUEST)).toEqual( 156 | 'application/x-protobuf;message=scalarparserequest', 157 | ); 158 | }); 159 | it('ScalarParseResponse message type', () => { 160 | expect(messageTypeToMime(MessageType.SCALAR_PARSE_RESPONSE)).toEqual( 161 | 'application/x-protobuf;message=scalarparseresponse', 162 | ); 163 | }); 164 | it('ScalarSerializeRequest message type', () => { 165 | expect(messageTypeToMime(MessageType.SCALAR_SERIALIZE_REQUEST)).toEqual( 166 | 'application/x-protobuf;message=scalarserializerequest', 167 | ); 168 | }); 169 | it('ScalarSerializeResponse message type', () => { 170 | expect(messageTypeToMime(MessageType.SCALAR_SERIALIZE_RESPONSE)).toEqual( 171 | 'application/x-protobuf;message=scalarserializeresponse', 172 | ); 173 | }); 174 | it('UnionResolveTypeRequest message type', () => { 175 | expect(messageTypeToMime(MessageType.UNION_RESOLVE_TYPE_REQUEST)).toEqual( 176 | 'application/x-protobuf;message=unionresolvetyperequest', 177 | ); 178 | }); 179 | it('UnionResolveTypeResponse message type', () => { 180 | expect(messageTypeToMime(MessageType.UNION_RESOLVE_TYPE_RESPONSE)).toEqual( 181 | 'application/x-protobuf;message=unionresolvetyperesponse', 182 | ); 183 | }); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /tests/raw/scalar.test.ts: -------------------------------------------------------------------------------- 1 | import * as messages from '../../src/proto/driver/messages'; 2 | import { scalarParseHandler, scalarSerializeHandler } from '../../src/raw/scalar.js'; 3 | import { join } from 'path'; 4 | describe('scalar', () => { 5 | const cwd = process.cwd(); 6 | beforeEach(() => { 7 | process.chdir(join(cwd, 'tests', 'raw', 'testdata')); 8 | }); 9 | afterEach(() => { 10 | process.chdir(cwd); 11 | }); 12 | it('parse handler checks content type', async () => { 13 | const data: Array<{ 14 | contentType: string; 15 | expectedErrorMessage: string; 16 | assertion: (expected: Uint8Array, actual: Uint8Array) => void; 17 | }> = [ 18 | { 19 | contentType: 'application/x-protobuf', 20 | expectedErrorMessage: '"application/x-protobuf" is not a valid content-type', 21 | assertion: (expected, actual: Uint8Array): void => { 22 | expect(actual).toEqual(expected); 23 | }, 24 | }, 25 | { 26 | contentType: 'application/x-protobuf;message=ScalarParseRequest', 27 | expectedErrorMessage: '"application/x-protobuf;message=ScalarParseRequest" is not a valid content-type', 28 | assertion: (expected, actual: Uint8Array): void => { 29 | expect(actual).not.toEqual(expected); 30 | }, 31 | }, 32 | ]; 33 | await Promise.all( 34 | data.map(async (tc) => { 35 | const expectedResponse = new messages.ScalarParseResponse(); 36 | const responseError = new messages.Error(); 37 | responseError.setMsg(tc.expectedErrorMessage); 38 | expectedResponse.setError(responseError); 39 | tc.assertion(expectedResponse.serializeBinary(), await scalarParseHandler(tc.contentType, new Uint8Array())); 40 | return Promise.resolve(); 41 | }), 42 | ); 43 | }); 44 | it('parse handler calls handler', async () => { 45 | const req = new messages.ScalarParseRequest(); 46 | const func = new messages.Function(); 47 | func.setName('scalar_nil_parse'); 48 | req.setFunction(func); 49 | const expected = new messages.ScalarParseResponse(); 50 | const nilObject = new messages.Value(); 51 | nilObject.setNil(true); 52 | expected.setValue(nilObject); 53 | const response = await scalarParseHandler( 54 | 'application/x-protobuf;message=ScalarParseRequest', 55 | req.serializeBinary(), 56 | ); 57 | expect(response).toEqual(expected.serializeBinary()); 58 | }); 59 | it('serialize handler checks content type', async () => { 60 | const data: Array<{ 61 | contentType: string; 62 | expectedErrorMessage: string; 63 | assertion: (expected: Uint8Array, actual: Uint8Array) => void; 64 | }> = [ 65 | { 66 | contentType: 'application/x-protobuf', 67 | expectedErrorMessage: '"application/x-protobuf" is not a valid content-type', 68 | assertion: (expected, actual: Uint8Array): void => { 69 | expect(actual).toEqual(expected); 70 | }, 71 | }, 72 | { 73 | contentType: 'application/x-protobuf;message=ScalarSerializeRequest', 74 | expectedErrorMessage: '"application/x-protobuf;message=ScalarSerializeRequest" is not a valid content-type', 75 | assertion: (expected, actual: Uint8Array): void => { 76 | expect(actual).not.toEqual(expected); 77 | }, 78 | }, 79 | ]; 80 | await Promise.all( 81 | data.map(async (tc) => { 82 | const expectedResponse = new messages.ScalarSerializeResponse(); 83 | const responseError = new messages.Error(); 84 | responseError.setMsg(tc.expectedErrorMessage); 85 | expectedResponse.setError(responseError); 86 | tc.assertion( 87 | expectedResponse.serializeBinary(), 88 | await scalarSerializeHandler(tc.contentType, new Uint8Array()), 89 | ); 90 | return Promise.resolve(); 91 | }), 92 | ); 93 | }); 94 | it('serialize handler calls handler', async () => { 95 | const req = new messages.ScalarSerializeRequest(); 96 | const func = new messages.Function(); 97 | func.setName('scalar_nil_serialize'); 98 | req.setFunction(func); 99 | const expected = new messages.ScalarSerializeResponse(); 100 | const nilObject = new messages.Value(); 101 | nilObject.setNil(true); 102 | expected.setValue(nilObject); 103 | const response = await scalarSerializeHandler( 104 | 'application/x-protobuf;message=ScalarSerializeRequest', 105 | req.serializeBinary(), 106 | ); 107 | expect(response).toEqual(expected.serializeBinary()); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /tests/raw/set_secrets.test.ts: -------------------------------------------------------------------------------- 1 | import * as messages from '../../src/proto/driver/messages.js'; 2 | import { setSecretsHandler } from '../../src/raw/set_secrets.js'; 3 | describe('raw setSecrets resolve type handler', () => { 4 | it('checks content type', async () => { 5 | const data: Array<{ 6 | contentType: string; 7 | expectedErrorMessage: string; 8 | assertion: (expected: Uint8Array, actual: Uint8Array) => void; 9 | }> = [ 10 | { 11 | contentType: 'application/x-protobuf', 12 | expectedErrorMessage: '"application/x-protobuf" is not a valid content-type', 13 | assertion: (expected, actual: Uint8Array): void => { 14 | expect(actual).toEqual(expected); 15 | }, 16 | }, 17 | { 18 | contentType: 'application/x-protobuf;message=SetSecretsRequest', 19 | expectedErrorMessage: '"application/x-protobuf;message=SetSecretsRequest" is not a valid content-type', 20 | assertion: (expected, actual: Uint8Array): void => { 21 | expect(actual).not.toEqual(expected); 22 | }, 23 | }, 24 | ]; 25 | await Promise.all( 26 | data.map(async (tc) => { 27 | const expectedResponse = new messages.SetSecretsResponse(); 28 | const responseError = new messages.Error(); 29 | responseError.setMsg(tc.expectedErrorMessage); 30 | expectedResponse.setError(responseError); 31 | tc.assertion(expectedResponse.serializeBinary(), await setSecretsHandler(tc.contentType, new Uint8Array())); 32 | return Promise.resolve(); 33 | }), 34 | ); 35 | }); 36 | it('sets environment', async () => { 37 | const data: Array<{ 38 | body: Uint8Array; 39 | expected: Uint8Array; 40 | }> = [ 41 | { 42 | body: ((): Uint8Array => { 43 | const req = new messages.SetSecretsRequest(); 44 | const secrets: Array<[string, string]> = [['SECRET', 'VALUE']]; 45 | req.setSecretsList( 46 | secrets.map((secret): messages.Secret => { 47 | const protoSecret = new messages.Secret(); 48 | protoSecret.setKey(secret[0]); 49 | protoSecret.setValue(secret[1]); 50 | return protoSecret; 51 | }), 52 | ); 53 | return req.serializeBinary(); 54 | })(), 55 | expected: ((): Uint8Array => { 56 | const response = new messages.SetSecretsResponse(); 57 | return response.serializeBinary(); 58 | })(), 59 | }, 60 | ]; 61 | data.forEach(async (tc) => { 62 | const response = await setSecretsHandler('application/x-protobuf;message=SetSecretsRequest', tc.body); 63 | expect(process.env.SECRET).toEqual('VALUE'); 64 | delete process.env.SECRET; 65 | expect(response).toEqual(tc.expected); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/raw/testdata/field_nil_resolve.js: -------------------------------------------------------------------------------- 1 | export default () => null; 2 | -------------------------------------------------------------------------------- /tests/raw/testdata/interface_handler.js: -------------------------------------------------------------------------------- 1 | export default () => 'SomeType'; 2 | -------------------------------------------------------------------------------- /tests/raw/testdata/scalar_nil_parse.js: -------------------------------------------------------------------------------- 1 | export default () => null; 2 | -------------------------------------------------------------------------------- /tests/raw/testdata/scalar_nil_serialize.js: -------------------------------------------------------------------------------- 1 | export default () => null; 2 | -------------------------------------------------------------------------------- /tests/raw/testdata/union_handler.js: -------------------------------------------------------------------------------- 1 | export default () => 'SomeType'; 2 | -------------------------------------------------------------------------------- /tests/raw/union_resolve_type.test.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import * as messages from '../../src/proto/driver/messages.js'; 4 | import { unionResolveTypeHandler } from '../../src/raw/union_resolve_type.js'; 5 | describe('raw union resolve type handler', () => { 6 | const cwd = process.cwd(); 7 | beforeEach(() => { 8 | process.chdir(join(cwd, 'tests', 'raw', 'testdata')); 9 | }); 10 | afterEach(() => { 11 | process.chdir(cwd); 12 | }); 13 | it('checks content type', async () => { 14 | const data: Array<{ 15 | contentType: string; 16 | expectedErrorMessage: string; 17 | assertion: (expected: Uint8Array, actual: Uint8Array) => void; 18 | }> = [ 19 | { 20 | contentType: 'application/x-protobuf', 21 | expectedErrorMessage: '"application/x-protobuf" is not a valid content-type', 22 | assertion: (expected, actual: Uint8Array): void => { 23 | expect(actual).toEqual(expected); 24 | }, 25 | }, 26 | { 27 | contentType: 'application/x-protobuf;message=UnionResolveTypeRequest', 28 | expectedErrorMessage: '"application/x-protobuf;message=UnionResolveTypeRequest" is not a valid content-type', 29 | assertion: (expected, actual: Uint8Array): void => { 30 | expect(actual).not.toEqual(expected); 31 | }, 32 | }, 33 | ]; 34 | await Promise.all( 35 | data.map(async (tc) => { 36 | const expectedResponse = new messages.UnionResolveTypeResponse(); 37 | const responseError = new messages.Error(); 38 | responseError.setMsg(tc.expectedErrorMessage); 39 | expectedResponse.setError(responseError); 40 | tc.assertion( 41 | expectedResponse.serializeBinary(), 42 | await unionResolveTypeHandler(tc.contentType, new Uint8Array()), 43 | ); 44 | return Promise.resolve(); 45 | }), 46 | ); 47 | }); 48 | it('calls handler', async () => { 49 | const req = new messages.UnionResolveTypeRequest(); 50 | req.setInfo(new messages.UnionResolveTypeInfo()); 51 | const func = new messages.Function(); 52 | func.setName('union_handler'); 53 | req.setFunction(func); 54 | const expected = new messages.UnionResolveTypeResponse(); 55 | const typeRef = new messages.TypeRef(); 56 | typeRef.setName('SomeType'); 57 | expected.setType(typeRef); 58 | const response = await unionResolveTypeHandler( 59 | 'application/x-protobuf;message=UnionResolveTypeRequest', 60 | req.serializeBinary(), 61 | ); 62 | expect(response).toEqual(expected.serializeBinary()); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/security/apiKey.test.ts: -------------------------------------------------------------------------------- 1 | import { ApiKeyAuth } from '../../src/security/apiKey.js'; 2 | import { IncomingMessage } from 'http'; 3 | 4 | describe('api key auth', () => { 5 | const mockRequest = (headers: Record) => ({ headers } as unknown as IncomingMessage); 6 | const xStuccoApiKey = 'x-stucco-api-key'; 7 | test('test authorizes valid x-stucco-api-key', () => 8 | expect(new ApiKeyAuth('xyz').authorize(mockRequest({ [xStuccoApiKey]: 'xyz' }))).resolves.toBeTruthy()); 9 | test('test fails nvalid x-stucco-api-key', () => 10 | expect(new ApiKeyAuth('xz').authorize(mockRequest({ [xStuccoApiKey]: 'xyz' }))).resolves.toBeFalsy()); 11 | test('test authorizes valid authorize', () => 12 | expect(new ApiKeyAuth('xyz').authorize(mockRequest({ authorization: 'bearer xyz' }))).resolves.toBeTruthy()); 13 | }); 14 | -------------------------------------------------------------------------------- /tests/security/cert.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { HttpCertReader } from '../../src/security/index.js'; 3 | import { ClientCertAuth } from '../../src/security/cert.js'; 4 | import { IncomingMessage } from 'http'; 5 | 6 | // Password to CA abcdef 7 | const certs = { 8 | cert: ` 9 | -----BEGIN CERTIFICATE----- 10 | MIIDETCCAfkCFEZa663fPA1euZ+yUq8ishBuQ0rQMA0GCSqGSIb3DQEBCwUAMEUx 11 | CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl 12 | cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjEwNDAyMTMyNzMyWhcNMzEwMzMxMTMy 13 | NzMyWjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE 14 | CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC 15 | AQ8AMIIBCgKCAQEA35N4wpb6CqtOfeq7q85reMeDtlz5r9+M6jhcVp+xEdDVLoXK 16 | 6wWb/lu3lsLNX+Eezakq+O7cNBRSTGn/APAaaCUKHvqmdoztYMtbP00BU67qBQSh 17 | x5DVn9x8pD6JDWGq9U334rmi4bPPIxw5dgtsl24Et5pw/Ef545aBwlaH/96y/sER 18 | xDZghP2pwMzgI5VG2HU+tJTpWLA3H8u6VHhQq+0s9dnmoVHjk0aLhU7KZsIx9VoA 19 | ao+O816PAXSJfD2+Qqb4QHvAsSna7Bt6NL1aCE7oR+Vvmwve5sE1j/0KhG/zy6wN 20 | 1kE1TK9gOjpxunYPzSuqlJA7E6iLuG+mW0ylcwIDAQABMA0GCSqGSIb3DQEBCwUA 21 | A4IBAQAVZKhJlWMAvOkKoZCVWCmbeNDesQF2x+uHG8WlAw/MnaxLcLhKXXMomYCL 22 | YQTawKzpMF5nH3vJZ57W8UpWmVkXPX+ZH/hdmX3BzLscoBfuLbbAALTPwrPuPBZB 23 | BPckDvB6gHJPqCl3l07KW+pBcRkJ2x6sHbxjkDxpS7aVPnZQjLRLL9NnDMK+OcRD 24 | WkWAseHuqlt/ZICG4bUgXZrO5+fOEU4jck4EVB9noIqQBEfRGJ94X2TJ5tFHNoJs 25 | OExToo82rWe3KO9m6+MU88Y8lVVY7RYOtW5i0K8Rt6KHvFpR7eJBDI5J2BXEDb2D 26 | sHg0g/fw3+XO3kHRl8CQeCTU1c7z 27 | -----END CERTIFICATE-----`, 28 | key: ` 29 | -----BEGIN PRIVATE KEY----- 30 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDfk3jClvoKq059 31 | 6rurzmt4x4O2XPmv34zqOFxWn7ER0NUuhcrrBZv+W7eWws1f4R7NqSr47tw0FFJM 32 | af8A8BpoJQoe+qZ2jO1gy1s/TQFTruoFBKHHkNWf3HykPokNYar1TffiuaLhs88j 33 | HDl2C2yXbgS3mnD8R/njloHCVof/3rL+wRHENmCE/anAzOAjlUbYdT60lOlYsDcf 34 | y7pUeFCr7Sz12eahUeOTRouFTspmwjH1WgBqj47zXo8BdIl8Pb5CpvhAe8CxKdrs 35 | G3o0vVoITuhH5W+bC97mwTWP/QqEb/PLrA3WQTVMr2A6OnG6dg/NK6qUkDsTqIu4 36 | b6ZbTKVzAgMBAAECggEAI59RnF+F03Fb/kAKSuOGyCWx3LqPpfAOebslK0AibF5D 37 | uTfkDvJD2pEufTzokCBEUixkBmm4eCvMuRQiZznaW0GbjTgOkdD+eW+tSDaywWyb 38 | KNWGGVAAWYo96cV0/MbVAGS93EgLpb6KgGOc3CwRz0beRYq7+dZWAGcYoag73w6G 39 | OsQulNHg3d/ZDAbMD8sy7+RUSKdpMcTQyVrBoJgVtjK1EdaS6RLI19l2Ux6Bjzo3 40 | NF8VppLmXdG/08r9zOvhr869OR8w+jsVK01jRN73wHsB0lRxNcyFFdCjPA1Y+Kkf 41 | QnR+wRgDjiVsmGxqG53nkvdxbGT0uXV5mqgDybocMQKBgQD/qsAJAQhSK4Y5dtR/ 42 | e5NKxEybrMO5WZOP0uoL6eB7jQ4Jc7cSr20WcAK6ExCu63j5Y1BZIOo4yagiO65x 43 | vjeiWVvaIfo33aZyDOI5yerrh1u/UyxLdlmA0sXkho1b3rtqZNWDcF58PquYDW83 44 | nSlComSUwzasjEoidqsTfGIttQKBgQDf3gVqBNLQVEBmuwsnqm2xFLdRXe0XQU5P 45 | YWNyao0WOqm2ATzYkAgn7k1ThBH2E3078He7QJw+EfpYCvzr3C/SLHU2JgwAIxeN 46 | ZFV7AUw2t8yDx+abF0S1Za1UKHWwKscfQZREiVFGm76UGx4Hwc35OedjZD4euVQK 47 | CDZ3RYs/hwKBgHJmaClfQebqvNPHvUwR8pV5AsKB6s5sK6Amgz2zeBQwyMAn/Bor 48 | TwfENSQn1cY/bVFCRDithsDEUyyGQgd5UxGdJIGVxI3s60aLR0sOc8TSO5Z/1Aks 49 | Ot5u8cfRAT3Di18PIY7/3/d+X2/ZSxO6ijTbz1/Vfgh1edK0ANbmSFQlAoGBAK4t 50 | Ie1A33zzcD/9m0o7UakLQy3tdEA5sWIVlbg5qpf3AH/5KowcVBwtTsCB6y+YLkHq 51 | cF2igW3RswO5WNtxr0tJB9EffQrGQtbhj5hqhA+2pUqKx6M3UWAJQfhOmnJ8dfyd 52 | m2xPoorbNkYpaw4B/e3A3YT5Q1PIQdikVywpUZQVAoGBALnHYjP6oMQWov0hQYXD 53 | FUhC+E5VGg4dSDKFXAtFQo/YdZsRs3Bi04v2XBmNlO4FUWcwuly2Tt57MZnhC2+w 54 | 5E3qNEUfkYrl+Cu1cAN0Qi8KtOe76gTA/VKQgxeW05dh51oobPFPs3ogINzPwHka 55 | KbbDc2AFvxw8mKZFvpaF7HCx 56 | -----END PRIVATE KEY-----`, 57 | ca: ` 58 | -----BEGIN CERTIFICATE----- 59 | MIIDazCCAlOgAwIBAgIUaJ/lmg0L28ddlpK3coV5NMwj4NAwDQYJKoZIhvcNAQEL 60 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 61 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMTA0MDIxMzI2MjRaFw0zMTAz 62 | MzExMzI2MjRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 63 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB 64 | AQUAA4IBDwAwggEKAoIBAQCXw33B8dpCtAAWjO8vWYvushsRkLrpVHVfG6ALeNcu 65 | MoOt6m3IOGIqx7u36OQoTJZP1qx7NoX3Ohic52PihZoASsCB/JDDZdSWQ+Ej3TCq 66 | ZI6LrmtRvV9r6hg5gm/BshzKl+RTqau7UyApSh2emZy7Q6sQ/4hR8Jyr3mBrfBL4 67 | aABcGgwMAE/u5IjuoT/Qfk8AhLGAUwRDtiwsgQlSrWuA6K/CjUMvWqSce0ylJnEN 68 | 2zWaXryKsDLru3/GoX/OGZqzPH1/5sosePd7gr9b54qMKCLgHS/F/QEL93KX8UyX 69 | q8UZhLbrgOKMCaNHTMvbilyuD6Wry3dq4R0QBHkq3NGrAgMBAAGjUzBRMB0GA1Ud 70 | DgQWBBS/T9gzaW20LjX8ZRQlMSeGi0BltzAfBgNVHSMEGDAWgBS/T9gzaW20LjX8 71 | ZRQlMSeGi0BltzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB5 72 | jkgUVlCHcMfWLxAGXlt58ZGLj79/NNkbKFS1vqd1p/l37s5bxpcrxtWW5aRhueMx 73 | ginR7XcSHmWhkuibKRlrwO8fvk9wbOGCWP5F6quMlsgxhboqKQPDKUzrOXlcGcXi 74 | uM72C7iAMdcbtPOGUtCudMzk8/tIN98wfGXXXlOa+yY55/6KttpHMBY+Giw2hre5 75 | gMEQbeP9OaP9g5Hlt6VzFrWLMVWJfkuZq4lR1Pj+zk+G7cxaedIcYfN+kI9C6Ywt 76 | ekho35AKRDg7b9iCr6L2XRuxN0DM30Oc7g5IJeL4IMoM6CzgwoAWOFg9G0ZpY+61 77 | 8K1JMAY4iq/436Q+7VFK 78 | -----END CERTIFICATE-----`, 79 | otherCa: ` 80 | -----BEGIN CERTIFICATE----- 81 | MIIDazCCAlOgAwIBAgIUapafjD5q5/YECs5PZsaoY82eM0UwDQYJKoZIhvcNAQEL 82 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 83 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMTA0MDIxMzMwMTNaFw0zMTAz 84 | MzExMzMwMTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 85 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB 86 | AQUAA4IBDwAwggEKAoIBAQD1ylNt1dJFvxWliUP4x/trZ57ufQSKsmnr05ATy7aw 87 | lOgRmwsODNLZGUH+IX94A2uDlZci59bAuIaA29vuhB+kLnJbbOs/ZIYbOk0EPdao 88 | 4OsRopI4d81gm2QHIYMatR5GrVmUmlR/ar8Hw32CoAjIpXXuGRFsjPZwfyZltLM9 89 | PS9UaWgqor89BAsDkTPVN1Ifa0frgZt4TJjocmug2b642z+icme3e3KJ4c6w5G2S 90 | vDqvBqXiLtox6eOEfpKhFdNGvJrFklBuCBf96FYtUj1eCj8QgRHuIz7V13houM+T 91 | dqLm/p8L0LsPVoUtCkrsO2aEd4cBofOkyTA05ZLwOkd5AgMBAAGjUzBRMB0GA1Ud 92 | DgQWBBR5ngq+xskfzBW5K36ZiSAU5oRBDTAfBgNVHSMEGDAWgBR5ngq+xskfzBW5 93 | K36ZiSAU5oRBDTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQDC 94 | HAyKYG8ilOfB3X2+uEpn3EoONE6vVMpgFG8Jb6TRtpQIwyfi663dVG5+8t2z9Km+ 95 | Mfzjd4yLCc3fAy6JEbXEogtjo3QjCq4tdjj5VH4zHwA6UNGpBGQETOuQfDIBVcq3 96 | gbS7qcfVHeUsS9GoOTqV2E1nGzrugpeNbtaS7Aksy0fWXY654JUIVLNGU98UXV9B 97 | 4wy5ZPwCkrO/E0xaVFQBIU536/H+wj+SQ/J6eZmEzqzVlsj26nKsO0FrjPRZa5UH 98 | bDNPPic3GJNH6RLErVRppoFSyLcgFeZyQIjBmQmMOdOFn9+VtaUGrkLRb1FoPUlD 99 | xajuhMHbEElSy3jjbncl 100 | -----END CERTIFICATE-----`, 101 | }; 102 | 103 | describe('test http cert reader', () => { 104 | jest.setTimeout(1000 * 360); 105 | test('http cert reader', async () => { 106 | const CERT_LINES = certs.cert.split('\n'); 107 | const START_SLICE = 2; 108 | const END_SLICE = CERT_LINES.length - 1; 109 | const BASE_64_CERT_LINES = CERT_LINES.slice(START_SLICE, END_SLICE); 110 | const reqMock = [ 111 | { 112 | socket: { 113 | getPeerCertificate: () => ({ 114 | raw: certs.cert, 115 | }), 116 | }, 117 | }, 118 | { 119 | socket: { 120 | getPeerCertificate: () => ({ 121 | raw: BASE_64_CERT_LINES.join(''), 122 | }), 123 | }, 124 | }, 125 | { 126 | socket: { 127 | getPeerCertificate: () => ({ 128 | raw: Buffer.from(BASE_64_CERT_LINES.join(''), 'base64'), 129 | }), 130 | }, 131 | }, 132 | { 133 | socket: { 134 | getPeerCertificate: () => ({ 135 | raw: Buffer.from(BASE_64_CERT_LINES.join('\n'), 'base64'), 136 | }), 137 | }, 138 | }, 139 | ]; 140 | const reader = new HttpCertReader(); 141 | await Promise.all( 142 | reqMock.map((req) => expect(reader.ReadCert(req as unknown as IncomingMessage)).resolves.toEqual(certs.cert)), 143 | ); 144 | }); 145 | describe('client cert auth', () => { 146 | const mockRequest = {} as unknown as IncomingMessage; 147 | const mockCertReader = { 148 | ReadCert: () => Promise.resolve(certs.cert), 149 | }; 150 | test('requires ca', async () => { 151 | const clientCaAuth = new ClientCertAuth(mockCertReader); 152 | await expect(clientCaAuth.authorize(mockRequest)).resolves.toBeFalsy(); 153 | }); 154 | test('validates self signed', async () => { 155 | const clientCaAuth = new ClientCertAuth(mockCertReader, certs); 156 | await expect(clientCaAuth.authorize(mockRequest)).resolves.toBeTruthy(); 157 | }); 158 | test('fails self signed with other ca', async () => { 159 | const clientCaAuth = new ClientCertAuth(mockCertReader, { ca: certs.otherCa }); 160 | await expect(clientCaAuth.authorize(mockRequest)).resolves.toBeFalsy(); 161 | }); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /tests/security/index.test.ts: -------------------------------------------------------------------------------- 1 | import { MultiAuth, DefaultSecurity } from '../../src/security/index.js'; 2 | import { IncomingMessage } from 'http'; 3 | 4 | describe('multi auth', () => { 5 | test('can create default security', () => { 6 | process.env['STUCCO_FUNCTION_KEY'] = 'xyz'; 7 | const sec = DefaultSecurity(); 8 | expect(sec && sec.authHandlers).toHaveLength(1); 9 | delete process.env['STUCCO_FUNCTION_KEY']; 10 | }); 11 | test('multi auth requries handlers', () => expect(() => new MultiAuth([])).toThrow()); 12 | const mockOkHandler = { 13 | authorize: () => Promise.resolve(true), 14 | }; 15 | const mockNotOkHandler = { 16 | authorize: () => Promise.resolve(false), 17 | }; 18 | const mockReq = {} as unknown as IncomingMessage; 19 | test('sets user handlers', () => expect(new MultiAuth([mockOkHandler]).authHandlers).toHaveLength(1)); 20 | test('passes with one success', () => 21 | expect(new MultiAuth([mockNotOkHandler, mockOkHandler]).authorize(mockReq)).toBeTruthy()); 22 | test('fails with no success', () => 23 | expect(new MultiAuth([mockNotOkHandler, mockNotOkHandler]).authorize(mockReq)).toBeTruthy()); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/server/hook_console.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import type { Hooks } from '../../src/server/hook_console.js'; 3 | import { fileURLToPath } from 'url'; 4 | const __filename = fileURLToPath(import.meta.url); 5 | 6 | describe('ConsoleHook', () => { 7 | beforeEach(() => { 8 | jest.resetModules(); 9 | }); 10 | function traceExpect(trace: string, firstLine = 'Trace: trace'): void { 11 | const traceLines = trace.split('\n'); 12 | expect(traceLines[0]).toEqual(firstLine); 13 | expect(traceLines[1]).toMatch( 14 | new RegExp('^.* \\(' + __filename.split('\\').join('\\\\') + ':[1-9][0-9]*:[1-9][0-9]*\\)$'), 15 | ); 16 | } 17 | it('creates and deletes a monkey patch', async () => { 18 | const { ConsoleHook } = await import('../../src/server/hook_console.js'); 19 | const mockConsole = { 20 | log: jest.fn(), 21 | info: jest.fn(), 22 | debug: jest.fn(), 23 | warn: jest.fn(), 24 | error: jest.fn(), 25 | trace: jest.fn(), 26 | }; 27 | const mockConsoleFunctions = { ...mockConsole }; 28 | const mockHooks = { 29 | log: jest.fn().mockReturnValue('hooked log'), 30 | info: jest.fn().mockReturnValue('hooked info'), 31 | debug: jest.fn().mockReturnValue('hooked debug'), 32 | warn: jest.fn().mockReturnValue('hooked warn'), 33 | error: jest.fn().mockReturnValue('hooked error'), 34 | trace: jest.fn().mockReturnValue('hooked trace'), 35 | }; 36 | const hook = new ConsoleHook(mockConsole, mockHooks as Hooks); 37 | expect(mockConsole.log).not.toBe(mockConsoleFunctions.log); 38 | expect(mockConsole.info).not.toBe(mockConsoleFunctions.info); 39 | expect(mockConsole.debug).not.toBe(mockConsoleFunctions.debug); 40 | expect(mockConsole.warn).not.toBe(mockConsoleFunctions.warn); 41 | expect(mockConsole.error).not.toBe(mockConsoleFunctions.error); 42 | expect(mockConsole.trace).not.toBe(mockConsoleFunctions.trace); 43 | hook.delete(); 44 | expect(mockConsole.log).toBe(mockConsoleFunctions.log); 45 | expect(mockConsole.info).toBe(mockConsoleFunctions.info); 46 | expect(mockConsole.debug).toBe(mockConsoleFunctions.debug); 47 | expect(mockConsole.warn).toBe(mockConsoleFunctions.warn); 48 | expect(mockConsole.error).toBe(mockConsoleFunctions.error); 49 | expect(mockConsole.trace).toBe(mockConsoleFunctions.trace); 50 | }); 51 | it('hooks and unhooks', async () => { 52 | const { ConsoleHook } = await import('../../src/server/hook_console.js'); 53 | const mockConsole = { 54 | log: jest.fn(), 55 | info: jest.fn(), 56 | debug: jest.fn(), 57 | warn: jest.fn(), 58 | error: jest.fn(), 59 | trace: jest.fn(), 60 | }; 61 | const mockConsoleFunctions = { ...mockConsole }; 62 | const mockHooks = { 63 | log: jest.fn().mockReturnValue('hooked log'), 64 | info: jest.fn().mockReturnValue('hooked info'), 65 | debug: jest.fn().mockReturnValue('hooked debug'), 66 | warn: jest.fn().mockReturnValue('hooked warn'), 67 | error: jest.fn().mockReturnValue('hooked error'), 68 | trace: jest.fn().mockReturnValue('hooked trace'), 69 | }; 70 | const errorCalls = mockConsoleFunctions.error.mock.calls; 71 | const hook = new ConsoleHook(mockConsole, mockHooks as Hooks); 72 | mockConsole.log('log'); 73 | expect(mockConsoleFunctions.log).toHaveBeenLastCalledWith('log'); 74 | mockConsole.info('info'); 75 | expect(mockConsoleFunctions.info).toHaveBeenLastCalledWith('info'); 76 | mockConsole.debug('debug'); 77 | expect(mockConsoleFunctions.debug).toHaveBeenLastCalledWith('debug'); 78 | mockConsole.warn('warn'); 79 | expect(mockConsoleFunctions.warn).toHaveBeenLastCalledWith('warn'); 80 | mockConsole.error('error'); 81 | expect(mockConsoleFunctions.error).toHaveBeenLastCalledWith('error'); 82 | mockConsole.trace('trace'); 83 | traceExpect.call(global, errorCalls[errorCalls.length - 1][0] as string); 84 | hook.hook(); 85 | mockConsole.log('log'); 86 | expect(mockConsoleFunctions.log).toHaveBeenLastCalledWith('hooked log'); 87 | mockConsole.info('info'); 88 | expect(mockConsoleFunctions.info).toHaveBeenLastCalledWith('hooked info'); 89 | mockConsole.debug('debug'); 90 | expect(mockConsoleFunctions.debug).toHaveBeenLastCalledWith('hooked debug'); 91 | mockConsole.warn('warn'); 92 | expect(mockConsoleFunctions.warn).toHaveBeenLastCalledWith('hooked warn'); 93 | mockConsole.error('error'); 94 | expect(mockConsoleFunctions.error).toHaveBeenLastCalledWith('hooked error'); 95 | mockConsole.trace('trace'); 96 | expect(mockConsoleFunctions.error).toHaveBeenLastCalledWith('hooked trace'); 97 | hook.unhook(); 98 | mockConsole.log('log'); 99 | expect(mockConsoleFunctions.log).toHaveBeenLastCalledWith('log'); 100 | mockConsole.info('info'); 101 | expect(mockConsoleFunctions.info).toHaveBeenLastCalledWith('info'); 102 | mockConsole.debug('debug'); 103 | expect(mockConsoleFunctions.debug).toHaveBeenLastCalledWith('debug'); 104 | mockConsole.warn('warn'); 105 | expect(mockConsoleFunctions.warn).toHaveBeenLastCalledWith('warn'); 106 | mockConsole.error('error'); 107 | expect(mockConsoleFunctions.error).toHaveBeenLastCalledWith('error'); 108 | mockConsole.trace('trace'); 109 | traceExpect.call(global, errorCalls[errorCalls.length - 1][0] as string); 110 | hook.delete(); 111 | }); 112 | it('adds level prefix', async () => { 113 | const { ConsoleHook } = await import('../../src/server/hook_console.js'); 114 | const mockConsole = { 115 | log: jest.fn(), 116 | info: jest.fn(), 117 | debug: jest.fn(), 118 | warn: jest.fn(), 119 | error: jest.fn(), 120 | trace: jest.fn(), 121 | }; 122 | const mockConsoleFunctions = { ...mockConsole }; 123 | const hook = new ConsoleHook(mockConsole); 124 | hook.hook(); 125 | mockConsole.log('log'); 126 | const errorCalls = mockConsoleFunctions.error.mock.calls; 127 | expect(mockConsoleFunctions.log).toHaveBeenLastCalledWith('[INFO]log'); 128 | mockConsole.info('info'); 129 | expect(mockConsoleFunctions.info).toHaveBeenLastCalledWith('[INFO]info'); 130 | mockConsole.debug('debug'); 131 | expect(mockConsoleFunctions.debug).toHaveBeenLastCalledWith('[DEBUG]debug'); 132 | mockConsole.warn('warn'); 133 | expect(mockConsoleFunctions.warn).toHaveBeenLastCalledWith('[WARN]warn'); 134 | mockConsole.error('error'); 135 | expect(mockConsoleFunctions.error).toHaveBeenLastCalledWith('[ERROR]error'); 136 | mockConsole.trace('trace'); 137 | traceExpect.call(global, errorCalls[errorCalls.length - 1][0] as string, '[TRACE]Trace: trace'); 138 | hook.delete(); 139 | }); 140 | it('Trace without message', async () => { 141 | const { ConsoleHook } = await import('../../src/server/hook_console.js'); 142 | const mockConsole = { 143 | log: jest.fn(), 144 | info: jest.fn(), 145 | debug: jest.fn(), 146 | warn: jest.fn(), 147 | error: jest.fn(), 148 | trace: jest.fn(), 149 | }; 150 | const mockConsoleFunctions = { ...mockConsole }; 151 | const hook = new ConsoleHook(mockConsole); 152 | const errorCalls = mockConsoleFunctions.error.mock.calls; 153 | mockConsole.trace(); 154 | traceExpect.call(global, errorCalls[errorCalls.length - 1][0] as string, 'Trace'); 155 | hook.delete(); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /tests/server/hook_stream.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | import { WriteStreamLike } from '../../src/server/hook_stream.js'; 4 | 5 | describe('StreamHook', () => { 6 | beforeEach(() => { 7 | jest.resetModules(); 8 | }); 9 | afterEach(() => { 10 | jest.resetModules(); 11 | }); 12 | it('creates and deletes monkey patch', async () => { 13 | const passthroughWrite = jest.fn(); 14 | const passthroughEnd = jest.fn(); 15 | const nodejsStreamMockWrite = jest.fn(); 16 | jest.unstable_mockModule('stream', () => { 17 | class PassThrough { 18 | public write = passthroughWrite; 19 | public end = passthroughEnd; 20 | } 21 | return { PassThrough }; 22 | }); 23 | const { StreamHook } = await import('../../src/server/hook_stream.js'); 24 | const stream = { write: nodejsStreamMockWrite }; 25 | const hook = new StreamHook(stream as WriteStreamLike); 26 | expect(stream.write).not.toBe(nodejsStreamMockWrite); 27 | hook.delete(); 28 | expect(stream.write).toBe(nodejsStreamMockWrite); 29 | expect(passthroughEnd).toHaveBeenCalledTimes(1); 30 | }); 31 | it('hooks and unhooks', async () => { 32 | const passthroughWrite = jest.fn(); 33 | const passthroughEnd = jest.fn(); 34 | const nodejsStreamMockWrite = jest.fn(); 35 | jest.unstable_mockModule('stream', () => { 36 | class PassThrough { 37 | public write = passthroughWrite; 38 | public end = passthroughEnd; 39 | } 40 | return { PassThrough }; 41 | }); 42 | const { StreamHook } = await import('../../src/server/hook_stream.js'); 43 | const stream = { write: nodejsStreamMockWrite }; 44 | const hook = new StreamHook(stream as WriteStreamLike); 45 | stream.write('unhooked'); 46 | expect(nodejsStreamMockWrite).toHaveBeenLastCalledWith('unhooked'); 47 | hook.hook(); 48 | stream.write('hooked'); 49 | expect(nodejsStreamMockWrite).toHaveBeenLastCalledWith('hooked'); 50 | expect(passthroughWrite).toHaveBeenLastCalledWith('hooked'); 51 | hook.unhook(); 52 | stream.write('unhooked'); 53 | expect(nodejsStreamMockWrite).toHaveBeenLastCalledWith('unhooked'); 54 | expect(nodejsStreamMockWrite).toHaveBeenCalledTimes(3); 55 | expect(passthroughWrite).toHaveBeenCalledTimes(1); 56 | hook.hook(); 57 | const encoding = 'encoding'; 58 | // eslint-disable-next-line @typescript-eslint/no-empty-function 59 | const cb = (): void => {}; 60 | stream.write('hooked', encoding, cb); 61 | expect(nodejsStreamMockWrite).toHaveBeenLastCalledWith('hooked', encoding, cb); 62 | expect(passthroughWrite).toHaveBeenLastCalledWith('hooked', encoding, cb); 63 | expect(passthroughWrite).toHaveBeenCalledTimes(2); 64 | stream.write('hooked', cb); 65 | expect(nodejsStreamMockWrite).toHaveBeenLastCalledWith('hooked', cb); 66 | expect(passthroughWrite).toHaveBeenLastCalledWith('hooked', cb); 67 | expect(passthroughWrite).toHaveBeenCalledTimes(3); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /tests/server/profiler.test.ts: -------------------------------------------------------------------------------- 1 | import { Profiler } from '../../src/server/profiler.js'; 2 | 3 | describe('test profiler', () => { 4 | it('falsy report if not enabled', () => { 5 | const profiler = new Profiler(); 6 | profiler.start(); 7 | expect(profiler.report()).toBeFalsy(); 8 | }); 9 | it('reports function profile', () => { 10 | const profiler = new Profiler({ enabled: true }); 11 | profiler.start(); 12 | expect( 13 | profiler.report({ 14 | hasFunction: (): boolean => true, 15 | getFunction: (): { getName: () => string } => ({ 16 | getName: (): string => 'some.function', 17 | }), 18 | }), 19 | ).toMatch(/^some.function took: [0-9]*\.[0-9]*ms$/); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/server/run.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | import { run, runPluginWith } from '../../src/server/run.js'; 4 | 5 | describe('local plugin server', () => { 6 | it('returns node config', () => { 7 | const oldWrite = process.stdout.write; 8 | const oldProcessArgv = process.argv; 9 | const fn = jest.fn(); 10 | try { 11 | process.stdout.write = fn as typeof process.stdout.write; 12 | process.argv = ['', '', 'config']; 13 | run({ version: 'v1.1.1' }); 14 | } finally { 15 | process.argv = oldProcessArgv; 16 | process.stdout.write = oldWrite; 17 | } 18 | expect(fn).toBeCalledTimes(1); 19 | expect(JSON.parse(fn.mock.calls[0][0] as string)).toEqual([ 20 | { 21 | provider: 'local', 22 | runtime: 'nodejs', 23 | }, 24 | { 25 | provider: 'local', 26 | runtime: 'nodejs-1', 27 | }, 28 | ]); 29 | }); 30 | it('starts and stops with process signals', () => { 31 | const serverMock = { 32 | serve: jest.fn(async () => { 33 | return; 34 | }), 35 | stop: jest.fn(() => { 36 | return Promise.resolve(); 37 | }), 38 | }; 39 | const mockProcessSignals = { 40 | on: jest.fn(), 41 | removeListener: jest.fn(), 42 | }; 43 | const oldOn = process.on; 44 | const oldOff = process.off; 45 | try { 46 | process.on = mockProcessSignals.on as typeof process.on; 47 | process.removeListener = mockProcessSignals.removeListener as typeof process.removeListener; 48 | runPluginWith(serverMock)(); 49 | } finally { 50 | process.on = oldOn; 51 | process.off = oldOff; 52 | } 53 | expect(serverMock.serve).toBeCalledTimes(1); 54 | expect(mockProcessSignals.on).toBeCalledTimes(2); 55 | expect(mockProcessSignals.on.mock.calls[0][0]).toEqual('SIGINT'); 56 | expect(mockProcessSignals.on.mock.calls[0][1]).toBeInstanceOf(Function); 57 | expect(mockProcessSignals.on.mock.calls[1][0]).toEqual('SIGTERM'); 58 | expect(mockProcessSignals.on.mock.calls[1][1]).toBeInstanceOf(Function); 59 | const stopCallback = mockProcessSignals.on.mock.calls[0][1] as () => unknown; 60 | stopCallback(); 61 | expect(mockProcessSignals.removeListener).toBeCalledTimes(2); 62 | expect(mockProcessSignals.removeListener.mock.calls[0][0]).toEqual('SIGINT'); 63 | expect(mockProcessSignals.removeListener.mock.calls[0][1]).toEqual(stopCallback); 64 | expect(mockProcessSignals.removeListener.mock.calls[1][0]).toEqual('SIGTERM'); 65 | expect(mockProcessSignals.removeListener.mock.calls[1][1]).toEqual(stopCallback); 66 | expect(serverMock.stop).toBeCalledTimes(1); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/server/testdata/field_resolve_handler.js: -------------------------------------------------------------------------------- 1 | export default () => 1; 2 | -------------------------------------------------------------------------------- /tests/server/testdata/interface_resolve_type_handler.js: -------------------------------------------------------------------------------- 1 | export default () => 'SomeType'; 2 | -------------------------------------------------------------------------------- /tests/server/testdata/scalar_parse_handler.js: -------------------------------------------------------------------------------- 1 | export default () => 1; 2 | -------------------------------------------------------------------------------- /tests/server/testdata/scalar_serialize_handler.js: -------------------------------------------------------------------------------- 1 | export default () => 1; 2 | -------------------------------------------------------------------------------- /tests/server/testdata/union_resolve_type_handler.js: -------------------------------------------------------------------------------- 1 | export default () => 'SomeType'; 2 | -------------------------------------------------------------------------------- /tests/server/testdata/user_error.js: -------------------------------------------------------------------------------- 1 | export default () => { throw new Error('some error'); }; 2 | -------------------------------------------------------------------------------- /tests/stucco/run.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { execFileSync } from 'child_process'; 3 | import { stucco } from '../../src/stucco/run.js'; 4 | import { version } from '../../src/stucco/version.js'; 5 | 6 | describe('stucco', () => { 7 | // file downlaod, be a bit more permissive timeout 8 | jest.setTimeout(60000); 9 | test('fetch correct version', async () => { 10 | const bin = await stucco(); 11 | expect(execFileSync(bin, ['version']).toString().trim()).toEqual(version); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /tests/stucco/version.test.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../../src/stucco/version.js'; 2 | 3 | test('version set', () => { 4 | expect(version).toEqual(expect.stringMatching(/^v[0-9]+\.[0-9]+\.[0-9]+$/)); 5 | }); 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "strict":true, 5 | "baseUrl": ".", 6 | "outDir": "./lib", 7 | "rootDir": "./src", 8 | "sourceMap": true, 9 | "inlineSources": true, 10 | "declaration": true, 11 | "module": "es2020", 12 | "allowSyntheticDefaultImports": true, 13 | "stripInternal": true, 14 | "moduleResolution": "node", 15 | "target": "es2020", 16 | "typeRoots": ["./node_modules/@types", "./src/typings"], 17 | "types": ["node", "jest"] 18 | }, 19 | "include": ["./src/**/*"], 20 | "exclude": ["node_modules"] 21 | } 22 | --------------------------------------------------------------------------------