├── .nvmrc ├── Allserver.js ├── Transport.js ├── Client.js ├── ClientTransport.js ├── src ├── util.js ├── server │ ├── Transport.js │ ├── MemoryTransport.js │ ├── BullmqTransport.js │ ├── ExpressTransport.js │ ├── HttpTransport.js │ ├── LambdaTransport.js │ ├── GrpcTransport.js │ └── Allserver.js ├── client │ ├── ClientTransport.js │ ├── MemoryClientTransport.js │ ├── LambdaClientTransport.js │ ├── HttpClientTransport.js │ ├── BullmqClientTransport.js │ ├── GrpcClientTransport.js │ └── AllserverClient.js └── index.js ├── mandatory.proto ├── .editorconfig ├── LICENSE ├── example ├── allserver_example.proto └── example.js ├── test ├── integration │ ├── allserver_integration_test.proto │ ├── LocalLambdaClientTransport.js │ └── integration.test.js ├── client │ ├── HttpClientTransport.test.js │ ├── LambdaClientTransport.test.js │ └── AllserverClient.test.js └── server │ └── Allserver.test.js ├── .github └── workflows │ └── ci.yml ├── package.json ├── .gitignore └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 24 2 | -------------------------------------------------------------------------------- /Allserver.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./src/server/Allserver"); 2 | -------------------------------------------------------------------------------- /Transport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./src/server/Transport"); 2 | -------------------------------------------------------------------------------- /Client.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./src/client/AllserverClient"); 2 | -------------------------------------------------------------------------------- /ClientTransport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./src/client/ClientTransport"); 2 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const is = (o, type) => typeof o === type; 2 | module.exports = { 3 | isBoolean: (o) => is(o, "boolean"), 4 | isString: (o) => is(o, "string"), 5 | isFunction: (o) => is(o, "function"), 6 | isObject: (o) => is(o, "object"), 7 | isPlainObject: (o) => o && o.constructor === Object, 8 | uniq: (a) => Array.from(new Set(a)), 9 | }; 10 | -------------------------------------------------------------------------------- /mandatory.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | message Empty {} 4 | message Reply { bool success = 1; string code = 2; string message = 3; } 5 | 6 | message IntrospectReply { 7 | bool success = 1; 8 | string code = 2; 9 | string message = 3; 10 | string procedures = 4; // JSON 11 | string proto = 5; // file contents 12 | } 13 | 14 | service Allserver { 15 | rpc introspect (Empty) returns (IntrospectReply) {} 16 | } 17 | -------------------------------------------------------------------------------- /src/server/Transport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("stampit")({ 2 | name: "Transport", 3 | 4 | methods: { 5 | // async startServer(defaultCtx) {}, 6 | 7 | async stopServer() {}, 8 | 9 | // getProcedureName(ctx) {}, 10 | 11 | // isIntrospection(ctx) {}, 12 | 13 | async prepareNotFoundReply(/* ctx */) {}, 14 | async prepareProcedureErrorReply(/* ctx */) {}, 15 | async prepareIntrospectionReply(/* ctx */) {}, 16 | 17 | // reply(/* ctx */) {}, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*.js] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 4 13 | max_line_length = 120 14 | 15 | # We recommend you to keep these unchanged 16 | end_of_line = lf 17 | charset = utf-8 18 | trim_trailing_whitespace = true 19 | insert_final_newline = true 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /src/client/ClientTransport.js: -------------------------------------------------------------------------------- 1 | const { isString, isFunction } = require("../util"); 2 | 3 | module.exports = require("stampit")({ 4 | name: "ClientTransport", 5 | 6 | props: { 7 | uri: null, 8 | timeout: 60_000, 9 | }, 10 | 11 | init({ uri, timeout }) { 12 | if (!isFunction(this.introspect)) throw new Error("ClientTransport must implement introspect()"); 13 | if (!isFunction(this.call)) throw new Error("ClientTransport must implement call()"); 14 | 15 | this.uri = uri || this.uri; 16 | if (!isString(this.uri)) throw new Error("`uri` connection string is required"); 17 | 18 | this.timeout = timeout != null ? timeout : this.timeout; 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/server/MemoryTransport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./Transport").compose({ 2 | name: "MemoryTransport", 3 | 4 | props: { 5 | AllserverClient: require("../client/AllserverClient"), 6 | MemoryClientTransport: require("../client/MemoryClientTransport"), 7 | }, 8 | 9 | methods: { 10 | startServer(defaultCtx) { 11 | return this.AllserverClient({ 12 | transport: this.MemoryClientTransport({ allserverContext: { ...defaultCtx, memory: {} } }), 13 | }); 14 | }, 15 | 16 | reply(ctx) { 17 | return ctx.result; 18 | }, 19 | 20 | getProcedureName(ctx) { 21 | return ctx.procedureName; 22 | }, 23 | 24 | isIntrospection(ctx) { 25 | return this.getProcedureName(ctx) === ""; 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /src/client/MemoryClientTransport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./ClientTransport").compose({ 2 | name: "MemoryClientTransport", 3 | 4 | props: { 5 | _allserverContext: null, 6 | uri: "memory", 7 | }, 8 | 9 | init({ allserverContext }) { 10 | this._allserverContext = allserverContext || this._allserverContext; 11 | }, 12 | 13 | methods: { 14 | async introspect(ctx) { 15 | ctx.procedureName = ""; 16 | const result = await this.call(ctx); 17 | if (!result) throw Error(); // The ClientTransport expects us to throw if call fails 18 | return result; 19 | }, 20 | 21 | async call(ctx) { 22 | return this._allserverContext.allserver.handleCall(ctx); 23 | }, 24 | 25 | createCallContext(defaultCtx) { 26 | return { ...defaultCtx, ...this._allserverContext, memory: {} }; 27 | }, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Vasyl Boroviak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /example/allserver_example.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | message Empty {} 4 | message Reply { bool success = 1; string code = 2; string message = 3; } 5 | 6 | message IntrospectReply { 7 | bool success = 1; 8 | string code = 2; 9 | string message = 3; 10 | string procedures = 4; // JSON 11 | string proto = 5; // file contents 12 | } 13 | 14 | service Allserver { 15 | rpc introspect (Empty) returns (IntrospectReply) {} 16 | } 17 | 18 | service MyService { 19 | rpc sayHello (HelloRequest) returns (HelloReply) {} 20 | rpc introspection (IntrospectionRequest) returns (Reply) {} 21 | rpc gate(GateRequest) returns (GateReply) {} 22 | rpc throws(Empty) returns (Reply) {} 23 | } 24 | 25 | message HelloRequest { 26 | string name = 1; 27 | } 28 | message HelloReply { 29 | bool success = 1; 30 | string code = 2; 31 | string message = 3; 32 | string sayHello = 4; 33 | } 34 | 35 | message IntrospectionRequest { 36 | bool enable = 1; 37 | } 38 | 39 | message GateRequest { 40 | int32 number = 1; 41 | } 42 | message GateReply { 43 | bool success = 1; 44 | string code = 2; 45 | string message = 3; 46 | Gate gate = 4; 47 | message Gate { 48 | string name = 1; 49 | string lastVehicle = 2; 50 | int32 length = 3; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/integration/allserver_integration_test.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | message Empty {} 4 | message Reply { bool success = 1; string code = 2; string message = 3; } 5 | 6 | message IntrospectReply { 7 | bool success = 1; 8 | string code = 2; 9 | string message = 3; 10 | string procedures = 4; // JSON 11 | string proto = 5; // file contents 12 | } 13 | 14 | service Allserver { 15 | rpc introspect (Empty) returns (IntrospectReply) {} 16 | } 17 | 18 | service MyService { 19 | rpc sayHello (HelloRequest) returns (HelloReply) {} 20 | rpc introspection (IntrospectionRequest) returns (Reply) {} 21 | rpc gate(GateRequest) returns (GateReply) {} 22 | rpc throws(Empty) returns (Reply) {} 23 | rpc throwsBadArgs(Empty) returns (Reply) {} 24 | rpc forcedTimeout(Empty) returns (Reply) {} 25 | } 26 | 27 | message HelloRequest { 28 | string name = 1; 29 | } 30 | message HelloReply { 31 | bool success = 1; 32 | string code = 2; 33 | string message = 3; 34 | string sayHello = 4; 35 | } 36 | 37 | message IntrospectionRequest { 38 | bool enable = 1; 39 | } 40 | 41 | message GateRequest { 42 | int32 number = 1; 43 | } 44 | message GateReply { 45 | bool success = 1; 46 | string code = 2; 47 | string message = 3; 48 | Gate gate = 4; 49 | message Gate { 50 | string name = 1; 51 | string lastVehicle = 2; 52 | int32 length = 3; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Using getters all over the place to avoid loading files which won't be used. 2 | module.exports = { 3 | // server 4 | 5 | get Allserver() { 6 | return require("./server/Allserver"); 7 | }, 8 | get Transport() { 9 | return require("./server/Transport"); 10 | }, 11 | get HttpTransport() { 12 | return require("./server/HttpTransport"); 13 | }, 14 | get ExpressTransport() { 15 | return require("./server/ExpressTransport"); 16 | }, 17 | get LambdaTransport() { 18 | return require("./server/LambdaTransport"); 19 | }, 20 | get GrpcTransport() { 21 | return require("./server/GrpcTransport"); 22 | }, 23 | get BullmqTransport() { 24 | return require("./server/BullmqTransport"); 25 | }, 26 | get MemoryTransport() { 27 | return require("./server/MemoryTransport"); 28 | }, 29 | 30 | // client 31 | 32 | get AllserverClient() { 33 | return require("./client/AllserverClient"); 34 | }, 35 | get ClientTransport() { 36 | return require("./client/ClientTransport"); 37 | }, 38 | get HttpClientTransport() { 39 | return require("./client/HttpClientTransport"); 40 | }, 41 | get GrpcClientTransport() { 42 | return require("./client/GrpcClientTransport"); 43 | }, 44 | get BullmqClientTransport() { 45 | return require("./client/BullmqClientTransport"); 46 | }, 47 | get MemoryClientTransport() { 48 | return require("./client/MemoryClientTransport"); 49 | }, 50 | get LambdaClientTransport() { 51 | return require("./client/LambdaClientTransport"); 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test_unix: 5 | name: ${{ matrix.os-version }}, Node ${{ matrix.node-version }}, Redis ${{ matrix.redis-version }} 6 | runs-on: ${{ matrix.os-version }} 7 | strategy: 8 | matrix: 9 | os-version: [ubuntu-latest, macos-latest] 10 | node-version: ["20", "22", "24"] 11 | redis-version: [6] 12 | 13 | steps: 14 | - name: Git checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Use Node ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | 22 | - name: Setup redis 23 | uses: shogo82148/actions-setup-redis@v1 24 | with: 25 | redis-version: ${{ matrix.redis-version }} 26 | 27 | - run: npm install 28 | 29 | - run: npm test 30 | env: 31 | CI: true 32 | 33 | # Testing Windows separately because Github CI does not support Redis on Windows. Sigh. 34 | # We skip BullMQ (Redis-backed) integration test on Windows using `if (process.platform === "win32")` 35 | test_windows: 36 | name: Node ${{ matrix.node-version }}, ${{ matrix.os-version }} 37 | runs-on: ${{ matrix.os-version }} 38 | strategy: 39 | matrix: 40 | os-version: [windows-latest] 41 | node-version: ["20", "22", "24"] 42 | 43 | steps: 44 | - name: Git checkout 45 | uses: actions/checkout@v4 46 | 47 | - name: Use Node ${{ matrix.node-version }} 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: ${{ matrix.node-version }} 51 | 52 | - run: npm install 53 | 54 | - run: npm test 55 | env: 56 | CI: true 57 | -------------------------------------------------------------------------------- /test/client/HttpClientTransport.test.js: -------------------------------------------------------------------------------- 1 | const assert = require("node:assert/strict"); 2 | const { describe, it } = require("node:test"); 3 | 4 | const { HttpClientTransport } = require("../.."); 5 | 6 | describe("HttpClientTransport", () => { 7 | describe("#init", () => { 8 | it("should accept uri without end slash", () => { 9 | const transport = HttpClientTransport({ uri: "http://bla" }); 10 | 11 | assert.equal(transport.uri, "http://bla/"); 12 | }); 13 | 14 | it("should assign headers", () => { 15 | const transport = HttpClientTransport({ uri: "http://bla", headers: { authorization: "Basic token" } }); 16 | 17 | const ctx = transport.createCallContext({}); 18 | 19 | assert.equal(ctx.http.headers.authorization, "Basic token"); 20 | }); 21 | }); 22 | 23 | describe("#call", () => { 24 | it("should add response to context", async () => { 25 | const MockedTransport = HttpClientTransport.props({ 26 | async fetch(uri, options) { 27 | assert.equal(uri, "http://localhost/"); 28 | assert.equal(options.method, "POST"); 29 | return new globalThis.Response(JSON.stringify({ success: true, code: "OK", message: "called" }), { 30 | status: 200, 31 | headers: { "Content-Type": "application/json" }, 32 | }); 33 | }, 34 | }); 35 | 36 | const transport = MockedTransport({ uri: "http://localhost" }); 37 | 38 | const ctx = transport.createCallContext({ procedureName: "" }); 39 | const result = await transport.call(ctx); 40 | 41 | assert(ctx.http.response instanceof globalThis.Response); 42 | assert.deepEqual(result, { success: true, code: "OK", message: "called" }); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/server/BullmqTransport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./Transport").compose({ 2 | name: "BullmqTransport", 3 | 4 | props: { 5 | Worker: require("bullmq").Worker, 6 | _worker: null, 7 | _queueName: null, 8 | _connectionOptions: null, 9 | _workerOptions: null, 10 | }, 11 | 12 | init({ queueName = "Allserver", connectionOptions, workerOptions }) { 13 | if (!connectionOptions) { 14 | connectionOptions = { host: "localhost", port: 6379 }; 15 | } 16 | this._queueName = queueName || this._queueName; 17 | this._connectionOptions = connectionOptions || this._connectionOptions; 18 | this._workerOptions = workerOptions || this._workerOptions || {}; 19 | if (this._workerOptions.autorun === undefined) this._workerOptions.autorun = true; 20 | }, 21 | 22 | methods: { 23 | async startServer(defaultCtx) { 24 | this._worker = new this.Worker( 25 | this._queueName, 26 | async (job) => { 27 | const ctx = { ...defaultCtx, bullmq: { job }, arg: job.data }; 28 | return await ctx.allserver.handleCall(ctx); 29 | }, 30 | { 31 | connection: this._connectionOptions, 32 | ...this._workerOptions, 33 | } 34 | ); 35 | return await this._worker.waitUntilReady(); 36 | }, 37 | 38 | reply(ctx) { 39 | return ctx.result; 40 | }, 41 | 42 | async stopServer() { 43 | return this._worker.close(); 44 | }, 45 | 46 | getProcedureName(ctx) { 47 | return ctx.bullmq.job.name; 48 | }, 49 | 50 | isIntrospection(ctx) { 51 | return this.getProcedureName(ctx) === "introspect"; // could be conflicting with procedure name(s) 52 | }, 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "allserver", 3 | "version": "2.6.0", 4 | "description": "Multi-protocol RPC server and [optional] client. DX-first. Minimalistic. Boilerplate-less. Opinionated.", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "lint": "oxlint ./", 8 | "test": "node --test --test-force-exit --trace-deprecation", 9 | "cov": "nyc --reporter=html node --run test" 10 | }, 11 | "keywords": [ 12 | "simple", 13 | "rpc", 14 | "http", 15 | "grpc", 16 | "websocket", 17 | "lambda", 18 | "server", 19 | "microservice" 20 | ], 21 | "files": [ 22 | "src", 23 | "*.js", 24 | "mandatory.proto" 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/flash-oss/allserver" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/flash-oss/allserver/issues" 32 | }, 33 | "homepage": "https://github.com/flash-oss/allserver", 34 | "author": "Vasyl Boroviak", 35 | "license": "MIT", 36 | "peerDependencies": { 37 | "@aws-sdk/client-lambda": "3", 38 | "@grpc/grpc-js": "1", 39 | "@grpc/proto-loader": "0", 40 | "bullmq": "3 - 5", 41 | "express": "4 - 6", 42 | "micro": "10" 43 | }, 44 | "peerDependenciesMeta": { 45 | "@aws-sdk/client-lambda": { 46 | "optional": true 47 | }, 48 | "@grpc/grpc-js": { 49 | "optional": true 50 | }, 51 | "@grpc/proto-loader": { 52 | "optional": true 53 | }, 54 | "bullmq": { 55 | "optional": true 56 | }, 57 | "express": { 58 | "optional": true 59 | }, 60 | "micro": { 61 | "optional": true 62 | } 63 | }, 64 | "devDependencies": { 65 | "@aws-sdk/client-lambda": "^3.921.0", 66 | "@grpc/grpc-js": "^1.13.4", 67 | "@grpc/proto-loader": "^0.8.0", 68 | "bullmq": "^5.56.9", 69 | "cls-hooked": "^4.2.2", 70 | "express": "^4.21.2", 71 | "lambda-local": "^2.2.0", 72 | "micro": "^10.0.1", 73 | "nyc": "^17.1.0", 74 | "oxlint": "^1.25.0", 75 | "prettier": "^2.1.1" 76 | }, 77 | "dependencies": { 78 | "stampit": "^5.0.1" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###### Mac OS generated files 2 | .DS_Store 3 | .DS_Store? 4 | ._* 5 | .Spotlight-V100 6 | .Trashes 7 | ehthumbs.db 8 | Thumbs.db 9 | 10 | 11 | 12 | ##### node.js 13 | # Logs 14 | logs 15 | *.log 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directory 39 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 40 | node_modules 41 | .npmrc 42 | package-lock.json 43 | 44 | 45 | ##### Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion 46 | *.iml 47 | 48 | ## Directory-based project format: 49 | .idea/ 50 | # if you remove the above rule, at least ignore the following: 51 | 52 | # User-specific stuff: 53 | # .idea/workspace.xml 54 | # .idea/tasks.xml 55 | # .idea/dictionaries 56 | 57 | # Sensitive or high-churn files: 58 | # .idea/dataSources.ids 59 | # .idea/dataSources.xml 60 | # .idea/sqlDataSources.xml 61 | # .idea/dynamic.xml 62 | # .idea/uiDesigner.xml 63 | 64 | # Gradle: 65 | # .idea/gradle.xml 66 | # .idea/libraries 67 | 68 | # Mongo Explorer plugin: 69 | # .idea/mongoSettings.xml 70 | 71 | ## File-based project format: 72 | *.ipr 73 | *.iws 74 | 75 | ## Plugin-specific files: 76 | 77 | # IntelliJ 78 | /out/ 79 | 80 | # mpeltonen/sbt-idea plugin 81 | .idea_modules/ 82 | 83 | # JIRA plugin 84 | atlassian-ide-plugin.xml 85 | 86 | # Crashlytics plugin (for Android Studio and IntelliJ) 87 | com_crashlytics_export_strings.xml 88 | crashlytics.properties 89 | crashlytics-build.properties 90 | 91 | 92 | 93 | ##### VIM editor 94 | [._]*.s[a-w][a-z] 95 | [_]s[a-w][a-z] 96 | *.un~ 97 | Session.vim 98 | .netrwhist 99 | *~ 100 | -------------------------------------------------------------------------------- /test/integration/LocalLambdaClientTransport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../ClientTransport").compose({ 2 | name: "LocalLambdaClientTransport", 3 | 4 | props: { 5 | _lambdaLocal: require("lambda-local"), 6 | _lambdaHandler: null, 7 | }, 8 | 9 | init({ lambdaHandler }) { 10 | if (!this.uri.endsWith("/")) this.uri += "/"; 11 | this._lambdaHandler = lambdaHandler || this._lambdaHandler; 12 | 13 | this._lambdaLocal.setLogger({ log() {}, transports: [] }); // silence logs 14 | }, 15 | 16 | methods: { 17 | async introspect() { 18 | const result = await this.call(this.createCallContext({ procedureName: "" })); 19 | // The server-side Transport will not have the call result if introspection is not available on the server side, 20 | // but the server itself is up and running processing calls. 21 | if (!result.procedures) throw Error("The lambda introspection call returned nothing"); // The ClientTransport expects us to throw if call fails 22 | return result; 23 | }, 24 | 25 | async call({ procedureName = "", arg }) { 26 | let response = await this._lambdaLocal.execute({ 27 | event: { 28 | body: arg && JSON.stringify(arg), 29 | path: "/" + procedureName, 30 | }, 31 | lambdaFunc: { handler: this._lambdaHandler }, 32 | }); 33 | 34 | if (response.statusCode !== 200) { 35 | const text = response.body || ""; 36 | let error; 37 | if (text[0] === "{" && text[text.length - 1] === "}") { 38 | try { 39 | const json = JSON.parse(text); 40 | const message = (json && json.message) || text; 41 | error = new Error(message); 42 | if (json && json.code) error.code = json.code; 43 | } catch { 44 | // ignoring. Not a JSON 45 | } 46 | } 47 | if (!error) error = new Error(text); 48 | error.status = response.statusCode; 49 | throw error; 50 | } 51 | 52 | try { 53 | return JSON.parse(response.body); 54 | } catch (err) { 55 | err.code = "RPC_RESPONSE_IS_NOT_JSON"; 56 | throw err; 57 | } 58 | }, 59 | 60 | createCallContext(defaultCtx) { 61 | return { ...defaultCtx, lambda: {} }; 62 | }, 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /src/server/ExpressTransport.js: -------------------------------------------------------------------------------- 1 | const { parse: parseUrl } = require("node:url"); 2 | 3 | module.exports = require("./Transport").compose({ 4 | name: "ExpressTransport", 5 | 6 | props: { 7 | _express: require("express"), 8 | }, 9 | 10 | methods: { 11 | async deserializeRequest(ctx) { 12 | if (ctx.http.req.deserializationFailed) return false; 13 | // If there is no body we will use request query (aka search params) 14 | let arg = ctx.http.query; 15 | if (ctx.http.req.body && Object.keys(ctx.http.req.body).length > 0) arg = ctx.http.req.body; 16 | ctx.arg = arg; 17 | return true; 18 | }, 19 | 20 | async _handleRequest(ctx) { 21 | if (await this.deserializeRequest(ctx)) { 22 | await ctx.allserver.handleCall(ctx); 23 | } else { 24 | // HTTP protocol request was malformed (not expected structure). 25 | // We are not going to process it. 26 | ctx.result = { success: false, code: "ALLSERVER_BAD_REQUEST", message: "Can't parse JSON" }; 27 | ctx.http.statusCode = 400; 28 | this.reply(ctx); 29 | } 30 | }, 31 | 32 | startServer(defaultCtx) { 33 | return [ 34 | this._express.json(), 35 | (err, req, res, next) => { 36 | if (err.statusCode) req.deserializationFailed = err; // overriding the error, processing the request as normal 37 | next(); 38 | }, 39 | async (req, res) => { 40 | const ctx = { 41 | ...defaultCtx, 42 | http: { req, res, query: req.query || {} }, 43 | }; 44 | 45 | await this._handleRequest(ctx); 46 | }, 47 | ]; 48 | }, 49 | 50 | getProcedureName(ctx) { 51 | return parseUrl(ctx.http.req.url).pathname.substr(1); 52 | }, 53 | 54 | isIntrospection(ctx) { 55 | return this.getProcedureName(ctx) === ""; 56 | }, 57 | 58 | prepareNotFoundReply(ctx) { 59 | ctx.http.statusCode = 404; 60 | }, 61 | prepareProcedureErrorReply(ctx) { 62 | // Generic exception. 63 | ctx.http.statusCode = 500; 64 | 65 | // nodejs assert() exception. In HTTP world this likely means 400 "Bad Request". 66 | if (ctx.error.code === "ERR_ASSERTION") ctx.http.statusCode = 400; 67 | }, 68 | 69 | reply(ctx) { 70 | if (!ctx.http.statusCode) ctx.http.statusCode = 200; 71 | ctx.http.res.status(ctx.http.statusCode).json(ctx.result); 72 | }, 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /src/client/LambdaClientTransport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./ClientTransport").compose({ 2 | name: "LambdaClientTransport", 3 | 4 | props: { 5 | awsSdkLambdaClient: null, 6 | }, 7 | 8 | init() { 9 | if (!this.awsSdkLambdaClient) { 10 | const { Lambda } = require("@aws-sdk/client-lambda"); 11 | this.awsSdkLambdaClient = new Lambda(); 12 | } 13 | }, 14 | 15 | methods: { 16 | async introspect(ctx) { 17 | ctx.procedureName = ""; 18 | const result = await this.call(ctx); 19 | // The server-side Transport will not have the call result if introspection is not available on the server side, 20 | // but the server itself is up and running processing calls. 21 | if (!result.procedures) throw Error("The lambda introspection call returned nothing"); // The ClientTransport expects us to throw if call fails 22 | return result; 23 | }, 24 | 25 | async call(ctx) { 26 | let invocationResponse; 27 | try { 28 | invocationResponse = await this.awsSdkLambdaClient.invoke( 29 | { 30 | FunctionName: this.uri.substring("lambda://".length), 31 | Payload: JSON.stringify(ctx.arg), 32 | }, 33 | { requestTimeout: this.timeout } // this.timeout is a property of the parent ClientTransport 34 | ); 35 | ctx.lambda.response = invocationResponse; 36 | } catch (e) { 37 | if (e.name.includes("ProviderError") || e.name.includes("NotFound")) e.noNetToServer = true; 38 | throw e; 39 | } 40 | 41 | let json; 42 | try { 43 | json = JSON.parse(Buffer.from(invocationResponse.Payload)); 44 | } catch (e) { 45 | e.code = "ALLSERVER_RPC_RESPONSE_IS_NOT_JSON"; 46 | throw e; 47 | } 48 | 49 | const error = new Error("Bad response payload"); 50 | if (json.constructor !== Object) { 51 | error.code = "ALLSERVER_RPC_RESPONSE_IS_NOT_OBJECT"; 52 | throw error; 53 | } 54 | 55 | // Yes, the AWS Lambda sometimes returns a empty object when the Lambda runtime shuts down abruptly for no apparent reason. 56 | if (Object.keys(json).length === 0) { 57 | error.code = "ALLSERVER_RPC_RESPONSE_IS_EMPTY_OBJECT"; 58 | throw error; 59 | } 60 | 61 | return json; 62 | }, 63 | 64 | createCallContext(defaultCtx) { 65 | return { 66 | ...defaultCtx, 67 | lambda: {}, 68 | }; 69 | }, 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /src/client/HttpClientTransport.js: -------------------------------------------------------------------------------- 1 | const { isString, isObject } = require("../util"); 2 | 3 | module.exports = require("./ClientTransport").compose({ 4 | name: "HttpClientTransport", 5 | 6 | props: { 7 | fetch: globalThis.fetch, 8 | headers: { 9 | "Content-Type": "application/json; charset=utf-8", 10 | }, 11 | }, 12 | 13 | init({ headers, fetch }) { 14 | if (!this.uri.endsWith("/")) this.uri += "/"; 15 | if (isObject(headers)) this.headers = Object.assign(this.headers || {}, headers); 16 | this.fetch = fetch || this.fetch; 17 | }, 18 | 19 | methods: { 20 | async introspect(ctx) { 21 | ctx.procedureName = ""; 22 | return this.call(ctx); 23 | }, 24 | 25 | async call({ procedureName, http }) { 26 | let response; 27 | 28 | try { 29 | if (http && http.body !== undefined && !isString(http.body)) http.body = JSON.stringify(http.body); 30 | response = await this.fetch(this.uri + procedureName, http); 31 | http.response = response; 32 | } catch (err) { 33 | if (err.code === "ECONNREFUSED" || (err.cause && err.cause.code === "ECONNREFUSED")) 34 | err.noNetToServer = true; 35 | throw err; 36 | } 37 | 38 | if (!response.ok) { 39 | let error; 40 | 41 | // Try parsing error as text. 42 | const text = await response.text().catch((err) => { 43 | error = err; 44 | err.code = "ALLSERVER_RPC_RESPONSE_IS_NOT_TEXT"; 45 | }); 46 | 47 | // Try parsing error as JSON object. 48 | if (!error && text && text[0] === "{" && text[text.length - 1] === "}") { 49 | try { 50 | const json = JSON.parse(text); 51 | error = new Error((json && json.message) || text); 52 | if (json && json.code) error.code = json.code; 53 | } catch { 54 | // ignoring. Not a JSON 55 | } 56 | } 57 | 58 | if (!error) error = new Error(text); 59 | error.status = response.status; 60 | throw error; 61 | } 62 | 63 | // Response is 200 OK 64 | 65 | try { 66 | return await response.json(); 67 | } catch (err) { 68 | err.code = "ALLSERVER_RPC_RESPONSE_IS_NOT_JSON"; 69 | throw err; 70 | } 71 | }, 72 | 73 | createCallContext(defaultCtx) { 74 | return { 75 | ...defaultCtx, 76 | http: { 77 | method: "POST", 78 | body: defaultCtx.arg, 79 | headers: { ...this.headers }, 80 | }, 81 | }; 82 | }, 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /src/client/BullmqClientTransport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./ClientTransport").compose({ 2 | name: "BullmqClientTransport", 3 | 4 | props: { 5 | Queue: require("bullmq").Queue, 6 | QueueEvents: require("bullmq").QueueEvents, 7 | _queue: null, 8 | _queueEvents: null, 9 | _jobsOptions: null, 10 | }, 11 | 12 | init({ queueName = "Allserver", connectionOptions, timeout, jobsOptions }) { 13 | if (!connectionOptions) { 14 | const bullmqUrl = new URL(this.uri); 15 | connectionOptions = { 16 | host: bullmqUrl.hostname, 17 | port: bullmqUrl.port, 18 | username: bullmqUrl.username, 19 | password: bullmqUrl.password, 20 | db: Number(bullmqUrl.pathname.substr(1)) || 0, 21 | retryStrategy: null, // only one attempt to connect 22 | }; 23 | } 24 | 25 | this._queue = new this.Queue(queueName, { connection: connectionOptions }); 26 | this._queue.on("error", () => {}); // The only reason we subscribe is to avoid bullmq to print errors to console 27 | this._queueEvents = new this.QueueEvents(queueName, { connection: connectionOptions }); 28 | this._queueEvents.on("error", () => {}); // The only reason we subscribe is to avoid bullmq to print errors to console 29 | 30 | this._jobsOptions = jobsOptions || {}; 31 | if (this._jobsOptions.removeOnComplete === undefined) this._jobsOptions.removeOnComplete = 100; 32 | if (this._jobsOptions.removeOnFail === undefined) this._jobsOptions.removeOnFail = 100; 33 | if (this._jobsOptions.sizeLimit === undefined) this._jobsOptions.sizeLimit = 524288; // max data JSON size is 512KB 34 | }, 35 | 36 | methods: { 37 | async introspect(ctx) { 38 | ctx.procedureName = "introspect"; 39 | const jobReturnResult = await this.call(ctx); 40 | // The server-side Transport will not have job result if introspection is not available on the server side, 41 | // but the server itself is up and running processing calls. 42 | if (jobReturnResult == null) throw new Error("The bullmq introspection job returned nothing"); 43 | return jobReturnResult; 44 | }, 45 | 46 | async call({ procedureName, bullmq }) { 47 | try { 48 | await this._queue.waitUntilReady(); 49 | const job = await bullmq.queue.add(procedureName, bullmq.data, bullmq.jobsOptions); 50 | return await job.waitUntilFinished(bullmq.queueEvents, this.timeout); // this.timeout is a property of the parent ClientTransport 51 | } catch (err) { 52 | if (err.code === "ECONNREFUSED") err.noNetToServer = true; 53 | throw err; 54 | } 55 | }, 56 | 57 | createCallContext(defaultCtx) { 58 | return { 59 | ...defaultCtx, 60 | bullmq: { 61 | data: defaultCtx.arg, 62 | queue: this._queue, 63 | queueEvents: this._queueEvents, 64 | jobsOptions: this._jobsOptions, 65 | }, 66 | }; 67 | }, 68 | }, 69 | }); 70 | -------------------------------------------------------------------------------- /src/server/HttpTransport.js: -------------------------------------------------------------------------------- 1 | const http = require("node:http"); 2 | const { parse: parseUrl, URLSearchParams } = require("node:url"); 3 | 4 | module.exports = require("./Transport").compose({ 5 | name: "HttpTransport", 6 | 7 | props: { 8 | micro: require("micro"), 9 | port: process.env.PORT, 10 | }, 11 | 12 | init({ port }) { 13 | if (port) this.port = port; 14 | }, 15 | 16 | methods: { 17 | async deserializeRequest(ctx) { 18 | const bodyBuffer = await this.micro.buffer(ctx.http.req); 19 | let arg = ctx.http.query; 20 | try { 21 | // If there is no body we will use request query (aka search params) 22 | if (bodyBuffer.length !== 0) arg = await this.micro.json(ctx.http.req); 23 | ctx.arg = arg; 24 | return true; 25 | } catch { 26 | return false; 27 | } 28 | }, 29 | 30 | async _handleRequest(ctx) { 31 | if (await this.deserializeRequest(ctx)) { 32 | await ctx.allserver.handleCall(ctx); 33 | } else { 34 | // HTTP protocol request was malformed (not expected structure). 35 | // We are not going to process it. 36 | ctx.result = { success: false, code: "ALLSERVER_BAD_REQUEST", message: "Can't parse JSON" }; 37 | ctx.http.statusCode = 400; 38 | this.reply(ctx); 39 | } 40 | }, 41 | 42 | startServer(defaultCtx) { 43 | this.server = new http.Server( 44 | this.micro.serve(async (req, res) => { 45 | const ctx = { ...defaultCtx, http: { req, res, url: parseUrl(req.url) } }; 46 | 47 | ctx.http.query = {}; 48 | if (ctx.http.url.query) { 49 | for (const [key, value] of new URLSearchParams(ctx.http.url.query).entries()) { 50 | ctx.http.query[key] = value; 51 | } 52 | } 53 | 54 | await this._handleRequest(ctx); 55 | }) 56 | ); 57 | return new Promise((r) => this.server.listen(this.port, r)); 58 | }, 59 | stopServer() { 60 | return new Promise((r) => { 61 | if (this.server.closeIdleConnections) this.server.closeIdleConnections(); 62 | this.server.close(r); 63 | if (this.server.closeAllConnections) this.server.closeAllConnections(); 64 | }); 65 | }, 66 | 67 | getProcedureName(ctx) { 68 | return ctx.http.url.pathname.substr(1); 69 | }, 70 | 71 | isIntrospection(ctx) { 72 | return this.getProcedureName(ctx) === ""; 73 | }, 74 | 75 | prepareNotFoundReply(ctx) { 76 | ctx.http.statusCode = 404; 77 | }, 78 | prepareProcedureErrorReply(ctx) { 79 | // Generic exception. 80 | ctx.http.statusCode = 500; 81 | 82 | // nodejs assert() exception. In HTTP world this likely means 400 "Bad Request". 83 | if (ctx.error.code === "ERR_ASSERTION") ctx.http.statusCode = 400; 84 | }, 85 | 86 | reply(ctx) { 87 | if (!ctx.http.statusCode) ctx.http.statusCode = 200; 88 | this.micro.send(ctx.http.res, ctx.http.statusCode, ctx.result); 89 | }, 90 | }, 91 | }); 92 | -------------------------------------------------------------------------------- /example/example.js: -------------------------------------------------------------------------------- 1 | // Your universal procedures. 2 | const procedures = { 3 | sayHello({ name }) { 4 | return "Hello " + name; 5 | }, 6 | introspection({ enable }, { allserver }) { 7 | allserver.introspection = Boolean(enable); 8 | }, 9 | gate({ number }) { 10 | if (number === 0) return undefined; 11 | if (number === 1) return { length: 42 }; 12 | if (number === 2) return { name: "Golden Gate", lastVehicle: "0 seconds ago" }; 13 | return { success: false, code: "GATE_NOT_FOUND", message: `Gate ${number} was not found` }; 14 | }, 15 | getArg(arg) { 16 | return arg; 17 | }, 18 | throws() { 19 | this.ping.me(); 20 | }, 21 | createUser({ firstName, lastName }) { 22 | // call database ... 23 | return { success: true, code: "CREATED", user: { id: String(Math.random()).substr(2), firstName, lastName } }; 24 | }, 25 | }; 26 | 27 | const { Allserver, HttpTransport, GrpcTransport, AllserverClient } = require(".."); 28 | 29 | setTimeout(async () => { 30 | // HTTP 31 | console.log("\nHTTP"); 32 | 33 | const httpServer = Allserver({ procedures, transport: HttpTransport({ port: 40000 }) }); 34 | httpServer.start(); 35 | 36 | // const response = await ( 37 | // await fetch("http://localhost:40000/sayHello", { method: "POST", body: JSON.stringify({ name: user }) }) 38 | // ).json(); 39 | // console.log("Greeting:", response.message); 40 | 41 | const httpClient = AllserverClient({ uri: "http://localhost:40000" }); 42 | await callClientMethods(httpClient); 43 | await httpServer.stop(); 44 | 45 | // gRPC 46 | console.log("\ngRPC"); 47 | 48 | const grpcServer = Allserver({ 49 | procedures, 50 | transport: GrpcTransport({ protoFile: __dirname + "/allserver_example.proto", port: 50051 }), 51 | }); 52 | await grpcServer.start(); 53 | 54 | // const grpc = require("@grpc/grpc-js"); 55 | // const packageDefinition = require("@grpc/proto-loader").loadSync(__dirname + "/allserver_example.proto"); 56 | // const proto = grpc.loadPackageDefinition(packageDefinition); 57 | // const client = new proto.MyService("localhost:50051", grpc.credentials.createInsecure()); 58 | // const { promisify } = require("util"); 59 | // for (const k in client) if (typeof client[k] === "function") client[k] = promisify(client[k].bind(client)); 60 | 61 | const grpcClient = AllserverClient({ uri: "grpc://localhost:50051" }); 62 | await callClientMethods(grpcClient); 63 | await grpcServer.stop(); 64 | }, 1); 65 | 66 | async function callClientMethods(client) { 67 | console.log(1, await client.sayHello({ name: "world" })); 68 | console.log(2, await client.sayHello({ name: "world" })); 69 | 70 | let response = await client.introspect({}); 71 | console.log("Procedures:", response.procedures); 72 | 73 | await client.introspection({ enable: false }); 74 | console.log("Disabled introspection"); 75 | response = await client.introspect().catch((err) => err.message); 76 | console.log("Procedures:", response); 77 | await client.introspection({ enable: true }); 78 | console.log("Enabled introspection"); 79 | response = await client.introspect(); 80 | console.log("Procedures:", response.procedures); 81 | 82 | console.log("Gates 0, 1, 2, 3"); 83 | for (const number of [0, 1, 2, 3]) console.log(await client.gate({ number })); 84 | 85 | response = await client.throws({}); 86 | console.log("Throws:", response); 87 | } 88 | -------------------------------------------------------------------------------- /src/server/LambdaTransport.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./Transport").compose({ 2 | name: "LambdaTransport", 3 | 4 | methods: { 5 | async deserializeEvent(ctx) { 6 | if (ctx.lambda.isHttp) { 7 | const { body, query } = ctx.lambda.http; 8 | try { 9 | // If there is no body we will use request query (aka search params) 10 | if (!body) { 11 | ctx.arg = query || {}; 12 | } else { 13 | if ((typeof body === "string" && body[0] === "{") || Buffer.isBuffer(body)) { 14 | ctx.arg = JSON.parse(body); 15 | } else { 16 | return false; 17 | } 18 | } 19 | return true; 20 | } catch { 21 | return false; 22 | } 23 | } else { 24 | ctx.arg = ctx.lambda.invoke || {}; 25 | return true; 26 | } 27 | }, 28 | 29 | async _handleRequest(ctx) { 30 | if (await this.deserializeEvent(ctx)) { 31 | await ctx.allserver.handleCall(ctx); 32 | } else { 33 | // HTTP protocol request was malformed (not expected structure). 34 | // We are not going to process it. 35 | ctx.result = { success: false, code: "ALLSERVER_BAD_REQUEST", message: "Can't parse JSON" }; 36 | ctx.lambda.statusCode = 400; 37 | this.reply(ctx); 38 | } 39 | }, 40 | 41 | startServer(defaultCtx) { 42 | return async (event, context) => { 43 | return new Promise((resolve) => { 44 | const lambda = { event, context, resolve }; 45 | 46 | const path = (event && (event.path || event.requestContext?.http?.path)) || undefined; 47 | lambda.isHttp = Boolean(path); 48 | 49 | if (lambda.isHttp) { 50 | lambda.http = { 51 | path, 52 | query: event?.queryStringParameters, 53 | body: event?.body, 54 | headers: event?.headers, 55 | }; 56 | } else { 57 | lambda.invoke = event || {}; 58 | } 59 | 60 | this._handleRequest({ ...defaultCtx, lambda }); 61 | }); 62 | }; 63 | }, 64 | 65 | getProcedureName(ctx) { 66 | const { isHttp, http, invoke } = ctx.lambda; 67 | return (isHttp ? http.path.substr(1) : invoke._?.procedureName) || ""; // if no procedureName then it's an introspection 68 | }, 69 | 70 | isIntrospection(ctx) { 71 | return this.getProcedureName(ctx) === ""; 72 | }, 73 | 74 | prepareProcedureErrorReply(ctx) { 75 | // Generic exception. 76 | ctx.lambda.statusCode = 500; 77 | 78 | // nodejs assert() exception. In HTTP world this likely means 400 "Bad Request". 79 | if (ctx.error.code === "ERR_ASSERTION") ctx.lambda.statusCode = 400; 80 | }, 81 | prepareNotFoundReply(ctx) { 82 | ctx.lambda.statusCode = 404; 83 | }, 84 | 85 | reply(ctx) { 86 | if (ctx.lambda.isHttp) { 87 | if (!ctx.lambda.statusCode) ctx.lambda.statusCode = 200; 88 | ctx.lambda.resolve({ 89 | statusCode: ctx.lambda.statusCode, 90 | headers: { "content-type": "application/json" }, 91 | body: JSON.stringify(ctx.result), 92 | }); 93 | } else { 94 | ctx.lambda.resolve(ctx.result); 95 | } 96 | }, 97 | }, 98 | }); 99 | -------------------------------------------------------------------------------- /test/client/LambdaClientTransport.test.js: -------------------------------------------------------------------------------- 1 | const assert = require("node:assert/strict"); 2 | const { describe, it } = require("node:test"); 3 | 4 | const { LambdaClientTransport } = require("../.."); 5 | 6 | describe("LambdaClientTransport", () => { 7 | describe("#call", () => { 8 | it("should add response to context", async () => { 9 | const MockedTransport = LambdaClientTransport.props({ 10 | awsSdkLambdaClient: { 11 | async invoke({ FunctionName, Payload }) { 12 | assert.equal(FunctionName, "my-function"); 13 | assert.deepEqual(JSON.parse(Payload), { _: { procedureName: "" } }); 14 | return { 15 | Payload: Uint8Array.from( 16 | Buffer.from(JSON.stringify({ success: true, code: "OK", message: "called" })) 17 | ), 18 | }; 19 | }, 20 | }, 21 | }); 22 | const transport = MockedTransport({ uri: "lambda://my-function" }); 23 | const ctx = transport.createCallContext({ arg: { _: { procedureName: "" } } }); 24 | 25 | const result = await transport.call(ctx); 26 | 27 | assert(ctx.lambda?.response?.Payload instanceof Uint8Array); 28 | assert.deepEqual(result, { success: true, code: "OK", message: "called" }); 29 | }); 30 | 31 | it("should handle misconfiguration", async () => { 32 | class ProviderError extends Error { 33 | constructor(message) { 34 | super(message); 35 | this.name = "ProviderError"; 36 | } 37 | } 38 | class NotFound extends Error { 39 | constructor(message) { 40 | super(message); 41 | this.name = "NotFound"; 42 | } 43 | } 44 | 45 | for (const ErrorClass of [ProviderError, NotFound]) { 46 | const MockedTransport = LambdaClientTransport.props({ 47 | awsSdkLambdaClient: { 48 | async invoke() { 49 | throw new ErrorClass("ProviderError"); 50 | }, 51 | }, 52 | }); 53 | const transport = MockedTransport({ uri: "lambda://my-function" }); 54 | const ctx = transport.createCallContext({ arg: { _: { procedureName: "" } } }); 55 | 56 | let error; 57 | await transport.call(ctx).catch((err) => (error = err)); 58 | 59 | assert(error); 60 | assert.equal(error.noNetToServer, true); 61 | } 62 | }); 63 | 64 | it("should handle bad responses", async () => { 65 | const codeToPayloadMap = { 66 | ALLSERVER_RPC_RESPONSE_IS_NOT_JSON: "this is not a JSON", 67 | ALLSERVER_RPC_RESPONSE_IS_NOT_OBJECT: JSON.stringify([]), 68 | ALLSERVER_RPC_RESPONSE_IS_EMPTY_OBJECT: JSON.stringify({}), 69 | }; 70 | 71 | for (const [expectedCode, payloadAsString] of Object.entries(codeToPayloadMap)) { 72 | const MockedTransport = LambdaClientTransport.props({ 73 | awsSdkLambdaClient: { 74 | async invoke() { 75 | return { 76 | Payload: Uint8Array.from(Buffer.from(payloadAsString)), 77 | }; 78 | }, 79 | }, 80 | }); 81 | const transport = MockedTransport({ uri: "lambda://my-function" }); 82 | const ctx = transport.createCallContext({ arg: { _: { procedureName: "" } } }); 83 | 84 | let error; 85 | await transport.call(ctx).catch((err) => (error = err)); 86 | 87 | assert(error); 88 | assert(ctx.lambda?.response?.Payload instanceof Uint8Array); 89 | assert.equal(error.code, expectedCode); 90 | } 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/client/GrpcClientTransport.js: -------------------------------------------------------------------------------- 1 | const { isFunction } = require("../util"); 2 | 3 | module.exports = require("./ClientTransport").compose({ 4 | name: "GrpcClientTransport", 5 | 6 | props: { 7 | _fs: require("node:fs"), 8 | _grpc: require("@grpc/grpc-js"), 9 | _protoLoader: require("@grpc/proto-loader"), 10 | _grpcClientForIntrospection: null, 11 | _grpcClient: null, 12 | _credentials: null, 13 | }, 14 | 15 | init({ protoFile, credentials }) { 16 | this._credentials = credentials || this._credentials || this._grpc.credentials.createInsecure(); 17 | this._createIntrospectionClient(); 18 | if (protoFile) { 19 | this._createMainClient(protoFile); 20 | } 21 | }, 22 | 23 | methods: { 24 | _createIntrospectionClient() { 25 | if (this._grpcClientForIntrospection) this._grpcClientForIntrospection.close(); 26 | this._grpcClientForIntrospection = this._createClientFromCtor( 27 | this._grpc.loadPackageDefinition(this._protoLoader.loadSync(__dirname + "/../../mandatory.proto")) 28 | .Allserver 29 | ); 30 | }, 31 | 32 | _createMainClient(protoFile) { 33 | const pd = this._grpc.loadPackageDefinition(this._protoLoader.loadSync(protoFile)); 34 | 35 | const Ctor = Object.entries(this._grpc.loadPackageDefinition(pd)).find( 36 | ([k, v]) => isFunction(v) && k !== "Allserver" 37 | )[1]; 38 | if (this._grpcClient) this._grpcClient.close(); 39 | this._grpcClient = this._createClientFromCtor(Ctor && Ctor.service); 40 | }, 41 | 42 | _createClientFromCtor(Ctor) { 43 | return new Ctor(this.uri.substr(7), this._credentials); 44 | }, 45 | 46 | async introspect(/* ctx */) { 47 | let result; 48 | try { 49 | result = await new Promise((resolve, reject) => 50 | this._grpcClientForIntrospection.introspect({}, (err, result) => 51 | err ? reject(err) : resolve(result) 52 | ) 53 | ); 54 | } catch (err) { 55 | if (err.code === 14) { 56 | // No connection established. 57 | err.noNetToServer = true; 58 | // Recreating the introspection client fixes a bug. 59 | this._createIntrospectionClient(); 60 | } 61 | throw err; 62 | } 63 | 64 | if (result.proto) { 65 | // Writing to a temporary file because protoLoader.loadSync() supports files only. 66 | const fileName = require("path").join( 67 | require("os").tmpdir(), 68 | `grpc-client-${String(Math.random())}.proto` // The Math.random() does not repeat in billions attempts. 69 | ); 70 | this._fs.writeFileSync(fileName, result.proto); 71 | try { 72 | this._createMainClient(fileName); 73 | } finally { 74 | this._fs.unlinkSync(fileName); 75 | } 76 | } 77 | 78 | return result; 79 | }, 80 | 81 | async call({ procedureName = "", arg }) { 82 | if (!this._grpcClient) { 83 | const error = new Error("gRPC client was not yet initialised"); 84 | error.code = "ALLSERVER_GRPC_PROTO_MISSING"; 85 | throw error; 86 | } 87 | 88 | if (!this._grpcClient[procedureName]) { 89 | const error = new Error(`Procedure '${procedureName}' not found`); 90 | error.code = "ALLSERVER_PROCEDURE_NOT_FOUND"; 91 | throw error; 92 | } 93 | 94 | return new Promise((resolve, reject) => 95 | this._grpcClient[procedureName](arg || {}, (err, result) => (err ? reject(err) : resolve(result))) 96 | ); 97 | }, 98 | 99 | createCallContext(defaultCtx) { 100 | return { ...defaultCtx, grpc: {} }; 101 | }, 102 | }, 103 | }); 104 | -------------------------------------------------------------------------------- /src/server/GrpcTransport.js: -------------------------------------------------------------------------------- 1 | const { isPlainObject, isFunction } = require("../util"); 2 | 3 | module.exports = require("./Transport").compose({ 4 | name: "GrpcTransport", 5 | 6 | props: { 7 | _fs: require("node:fs"), 8 | _grpc: require("@grpc/grpc-js"), 9 | _protoLoader: require("@grpc/proto-loader"), 10 | port: process.env.PORT, 11 | protoFile: null, 12 | credentials: null, 13 | protoFileContents: null, 14 | options: null, 15 | }, 16 | 17 | init({ port, protoFile, options, credentials }) { 18 | if (port) this.port = port; 19 | if (protoFile) this.protoFile = protoFile; 20 | if (options) this.options = options; 21 | this.credentials = credentials || this.credentials || this._grpc.ServerCredentials.createInsecure(); 22 | }, 23 | 24 | methods: { 25 | _validateResponseTypes(obj, inspectedSet = new Set()) { 26 | inspectedSet.add(obj); // circle linking protection 27 | 28 | for (const [k, v] of Object.entries(obj)) { 29 | if (!isPlainObject(v) || inspectedSet.has(v)) continue; 30 | 31 | // Not sure how long this code would survive in modern fast pace days. 32 | if (k === "responseType") { 33 | let f = v.type.field[0]; 34 | const successOk = f && f.name === "success" && f.type === "TYPE_BOOL"; 35 | if (!successOk) throw new Error(`Method ${obj.originalName} must return "bool success = 1"`); 36 | 37 | f = v.type.field[1]; 38 | const codeOk = f && f.name === "code" && f.type === "TYPE_STRING"; 39 | if (!codeOk) throw new Error(`Method ${obj.originalName} must return "string code = 2"`); 40 | 41 | f = v.type.field[2]; 42 | const messageOk = f && f.name === "message" && f.type === "TYPE_STRING"; 43 | if (!messageOk) throw new Error(`Method ${obj.originalName} must return "string message = 3"`); 44 | } else { 45 | this._validateResponseTypes(v, inspectedSet); 46 | } 47 | } 48 | }, 49 | 50 | _validatePackageDefinition(packageDefinition) { 51 | if (!packageDefinition.Allserver || !packageDefinition.Allserver.introspect) { 52 | throw new Error(`Server .proto file is missing Allserver mandatory introspection declarations`); 53 | } 54 | 55 | this._validateResponseTypes(packageDefinition); 56 | }, 57 | 58 | async startServer(defaultCtx) { 59 | this.server = new this._grpc.Server(this.options); 60 | async function wrappedCallback(call, callback) { 61 | const ctx = { ...defaultCtx, arg: call.request, grpc: { call, callback } }; 62 | await ctx.allserver.handleCall(ctx); 63 | } 64 | 65 | const packageDefinition = this._protoLoader.loadSync(this.protoFile); 66 | this._validatePackageDefinition(packageDefinition); 67 | 68 | const protoDescriptor = this._grpc.loadPackageDefinition(packageDefinition); 69 | for (const typeOfProto of Object.values(protoDescriptor)) { 70 | if (!(isFunction(typeOfProto) && isPlainObject(typeOfProto.service))) continue; 71 | 72 | const proxies = { introspect: wrappedCallback }; 73 | for (const [name, impl] of Object.entries(defaultCtx.allserver.procedures)) { 74 | if (isFunction(impl)) proxies[name] = wrappedCallback; 75 | } 76 | this.server.addService(typeOfProto.service, proxies); 77 | } 78 | 79 | await new Promise((resolve, reject) => { 80 | this.server.bindAsync(`0.0.0.0:${this.port}`, this.credentials, (err, result) => 81 | err ? reject(err) : resolve(result) 82 | ); 83 | }); 84 | }, 85 | stopServer() { 86 | return new Promise((r) => this.server.tryShutdown(r)); 87 | }, 88 | 89 | getProcedureName(ctx) { 90 | return ctx.grpc.call.call.handler.path.split("/").pop(); 91 | }, 92 | 93 | isIntrospection(ctx) { 94 | return this.getProcedureName(ctx) === "introspect"; 95 | }, 96 | 97 | async prepareIntrospectionReply(ctx) { 98 | if (!this.protoFileContents) { 99 | this.protoFileContents = this._fs.readFileSync(this.protoFile, "utf8"); 100 | } 101 | 102 | ctx.result.proto = this.protoFileContents; 103 | }, 104 | 105 | reply(ctx) { 106 | ctx.grpc.callback(null, ctx.result); 107 | }, 108 | }, 109 | }); 110 | -------------------------------------------------------------------------------- /src/server/Allserver.js: -------------------------------------------------------------------------------- 1 | const assert = require("node:assert/strict"); 2 | 3 | const { isObject, isBoolean, isFunction, uniq } = require("../util"); 4 | 5 | module.exports = require("stampit")({ 6 | name: "Allserver", 7 | 8 | deepProps: { 9 | before: null, 10 | after: null, 11 | }, 12 | 13 | props: { 14 | procedures: {}, 15 | transport: null, 16 | logger: console, 17 | introspection: true, 18 | 19 | callsCount: 0, 20 | }, 21 | 22 | init({ procedures, transport, introspection, before, after, logger }) { 23 | this.procedures = procedures || this.procedures; 24 | this.transport = transport || this.transport || require("./HttpTransport")(); 25 | this.logger = logger || this.logger; 26 | this.introspection = introspection != null ? introspection : this.introspection; 27 | if (before) this.before = uniq([].concat(this.before).concat(before).filter(isFunction)); 28 | if (after) this.after = uniq([].concat(this.after).concat(after).filter(isFunction)); 29 | 30 | this._validateProcedures(); 31 | }, 32 | 33 | methods: { 34 | _validateProcedures() { 35 | assert(isObject(this.procedures), "'procedures' must be an object"); 36 | assert(Object.values(this.procedures).every(isFunction), "All procedures must be functions"); 37 | }, 38 | 39 | async _introspect(ctx) { 40 | const allow = isFunction(this.introspection) ? this.introspection(ctx) : this.introspection; 41 | if (!allow) return; 42 | 43 | const obj = {}; 44 | for (const [key, value] of Object.entries(this.procedures)) obj[key] = typeof value; 45 | ctx.introspection = obj; 46 | 47 | ctx.result = { 48 | success: true, 49 | code: "ALLSERVER_INTROSPECTION", 50 | message: "Introspection as JSON string", 51 | procedures: JSON.stringify(ctx.introspection), 52 | }; 53 | await this.transport.prepareIntrospectionReply(ctx); 54 | }, 55 | 56 | /** 57 | * This method does not throw. 58 | * The `ctx.procedure` is the function to call. 59 | * @param ctx 60 | * @return {Promise} 61 | * @private 62 | */ 63 | async _callProcedure(ctx) { 64 | if (!isFunction(ctx.procedure)) { 65 | ctx.result = { 66 | success: false, 67 | code: "ALLSERVER_PROCEDURE_NOT_FOUND", 68 | message: `Procedure '${ctx.procedureName}' not found`, 69 | }; 70 | await this.transport.prepareNotFoundReply(ctx); 71 | return; 72 | } 73 | 74 | let result; 75 | try { 76 | result = await ctx.procedure(ctx.arg, ctx); 77 | } catch (err) { 78 | const code = err.code || "ALLSERVER_PROCEDURE_ERROR"; 79 | this.logger.error(err, code); 80 | ctx.error = err; 81 | ctx.result = { 82 | success: false, 83 | code, 84 | message: `'${err.message}' error in '${ctx.procedureName}' procedure`, 85 | }; 86 | await this.transport.prepareProcedureErrorReply(ctx); 87 | return; 88 | } 89 | 90 | if (result === undefined) { 91 | ctx.result = { success: true, code: "SUCCESS", message: "Success" }; 92 | } else if (!result || !isBoolean(result.success)) { 93 | ctx.result = { 94 | success: true, 95 | code: "SUCCESS", 96 | message: "Success", 97 | [ctx.procedureName]: result, 98 | }; 99 | } else { 100 | ctx.result = result; 101 | } 102 | }, 103 | 104 | async _callMiddlewares(ctx, middlewareType, next) { 105 | const runMiddlewares = async (middlewares) => { 106 | if (!middlewares?.length) { 107 | // no middlewares to run 108 | if (next) return await next(); 109 | return; 110 | } 111 | const middleware = middlewares[0]; 112 | async function handleMiddlewareResult(result) { 113 | if (result !== undefined) { 114 | ctx.result = result; 115 | // Do not call any more middlewares 116 | } else { 117 | await runMiddlewares(middlewares.slice(1)); 118 | } 119 | } 120 | try { 121 | if (middleware.length > 1) { 122 | // This middleware accepts more than one argument 123 | await middleware.call(this, ctx, handleMiddlewareResult); 124 | } else { 125 | const result = await middleware.call(this, ctx); 126 | await handleMiddlewareResult(result); 127 | } 128 | } catch (err) { 129 | const code = err.code || "ALLSERVER_MIDDLEWARE_ERROR"; 130 | this.logger.error(err, code); 131 | ctx.error = err; 132 | ctx.result = { 133 | success: false, 134 | code, 135 | message: `'${err.message}' error in '${middlewareType}' middleware`, 136 | }; 137 | // Do not call any more middlewares 138 | if (next) return await next(); 139 | } 140 | }; 141 | 142 | const middlewares = [].concat(this[middlewareType]).filter(isFunction); 143 | 144 | return await runMiddlewares(middlewares); 145 | }, 146 | 147 | async handleCall(ctx) { 148 | ctx.callNumber = this.callsCount; 149 | this.callsCount += 1; 150 | ctx.procedureName = this.transport.getProcedureName(ctx); 151 | ctx.isIntrospection = this.transport.isIntrospection(ctx); 152 | if (!ctx.isIntrospection && ctx.procedureName) ctx.procedure = this.procedures[ctx.procedureName]; 153 | 154 | if (!ctx.arg) ctx.arg = {}; 155 | if (!ctx.arg._) ctx.arg._ = {}; 156 | if (!ctx.arg._.procedureName) ctx.arg._.procedureName = ctx.procedureName; 157 | 158 | await this._callMiddlewares(ctx, "before", async () => { 159 | if (!ctx.result) { 160 | if (ctx.isIntrospection) { 161 | await this._introspect(ctx); 162 | } else { 163 | await this._callProcedure(ctx); 164 | } 165 | } 166 | 167 | // Warning! This call might overwrite an existing result. 168 | await this._callMiddlewares(ctx, "after"); 169 | }); 170 | 171 | return this.transport.reply(ctx); 172 | }, 173 | 174 | start() { 175 | return this.transport.startServer({ allserver: this }); 176 | }, 177 | stop() { 178 | return this.transport.stopServer(); 179 | }, 180 | }, 181 | 182 | statics: { 183 | defaults({ procedures, transport, logger, introspection, before, after } = {}) { 184 | if (before != null) before = (Array.isArray(before) ? before : [before]).filter(isFunction); 185 | if (after != null) after = (Array.isArray(after) ? after : [after]).filter(isFunction); 186 | 187 | return this.compose({ 188 | props: { procedures, transport, logger, introspection }, 189 | deepProps: { before, after }, 190 | }); 191 | }, 192 | }, 193 | }); 194 | -------------------------------------------------------------------------------- /src/client/AllserverClient.js: -------------------------------------------------------------------------------- 1 | const { isString, isFunction, isObject } = require("../util"); 2 | 3 | // Protected variables 4 | const p = Symbol.for("AllserverClient"); 5 | 6 | function addProceduresToObject(allserverClient, procedures, proxyClient) { 7 | let error; 8 | try { 9 | procedures = JSON.parse(procedures); 10 | } catch (err) { 11 | error = err; 12 | } 13 | if (error || !isObject(procedures)) { 14 | const result = { 15 | success: false, 16 | code: "ALLSERVER_CLIENT_MALFORMED_INTROSPECTION", 17 | message: `Malformed introspection from ${allserverClient[p].transport.uri}`, 18 | }; 19 | if (error) result.error = error; 20 | return result; 21 | } 22 | 23 | const nameMapper = isFunction(allserverClient[p].nameMapper) ? allserverClient[p].nameMapper : (n) => n; 24 | for (let [procedureName, type] of Object.entries(procedures)) { 25 | procedureName = nameMapper(procedureName); 26 | if (!procedureName || type !== "function" || allserverClient[procedureName]) continue; 27 | allserverClient[procedureName] = (...args) => allserverClient.call.call(proxyClient, procedureName, ...args); 28 | } 29 | return { success: true }; 30 | } 31 | 32 | function proxyWrappingInitialiser() { 33 | // 34 | if (!this[p].dynamicMethods) return; 35 | 36 | // Wrapping our object instance. 37 | return new Proxy(this, { 38 | get: function (allserverClient, procedureName, proxyClient) { 39 | if (procedureName in allserverClient) { 40 | return Reflect.get(allserverClient, procedureName, proxyClient); 41 | } 42 | 43 | // Method not found! 44 | // Checking if automatic introspection is disabled or impossible. 45 | const uri = allserverClient[p].transport.uri; 46 | if (!allserverClient[p].autoIntrospect || !uri) { 47 | // Automatic introspection is disabled or impossible. Well... good luck. :) 48 | // Most likely this call would be successful. Unless client and server interfaces are incompatible. 49 | // 🤞 50 | return (...args) => allserverClient.call.call(proxyClient, procedureName, ...args); 51 | } 52 | 53 | const introspectionCache = allserverClient.__proto__._introspectionCache; 54 | // Let's see if we already introspected that server. 55 | const introspectionResult = introspectionCache.get(uri); 56 | if (introspectionResult && introspectionResult.success && introspectionResult.procedures) { 57 | // Yeah. We already successfully introspected it. 58 | addProceduresToObject(allserverClient, introspectionResult.procedures, proxyClient); 59 | if (procedureName in allserverClient) { 60 | // The PREVIOUS auto introspection worked as expected. It added a method to the client object. 61 | return Reflect.get(allserverClient, procedureName, proxyClient); 62 | } else { 63 | if (allserverClient[p].callIntrospectedProceduresOnly) { 64 | return () => ({ 65 | success: false, 66 | code: "ALLSERVER_CLIENT_PROCEDURE_NOT_FOUND", 67 | message: `Procedure '${procedureName}' not found via introspection`, 68 | }); 69 | } 70 | 71 | // The method `name` was not present in the introspection, so let's call server side. 72 | // 🤞 73 | return (...args) => allserverClient.call.call(proxyClient, procedureName, ...args); 74 | } 75 | } 76 | 77 | // Ok. Automatic introspection is necessary. Let's do it. 78 | return async (...args) => { 79 | const introspectionResult = await allserverClient.introspect.call(proxyClient); 80 | 81 | if (introspectionResult && introspectionResult.success && introspectionResult.procedures) { 82 | // The automatic introspection won't be executed if you create a second instance of the AllserverClient with the same URI! :) 83 | const result = addProceduresToObject(allserverClient, introspectionResult.procedures, proxyClient); 84 | if (result.success) { 85 | // Do not cache unsuccessful introspections 86 | introspectionCache.set(uri, introspectionResult); 87 | } else { 88 | // Couldn't apply introspection to the client object. 89 | return result; 90 | } 91 | } 92 | 93 | if (procedureName in allserverClient) { 94 | // This is the main happy path. 95 | // The auto introspection worked as expected. It added a method to the client object. 96 | return Reflect.get(allserverClient, procedureName, proxyClient)(...args); 97 | } else { 98 | if (introspectionResult && introspectionResult.noNetToServer) { 99 | return { 100 | success: false, 101 | code: "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE", 102 | message: `Couldn't reach remote procedure: ${procedureName}`, 103 | noNetToServer: introspectionResult.noNetToServer, 104 | error: introspectionResult.error, 105 | }; 106 | } else { 107 | if (allserverClient[p].callIntrospectedProceduresOnly) { 108 | if (!introspectionResult || !introspectionResult.success) { 109 | const ir = introspectionResult || {}; 110 | return { 111 | ...ir, // we need to leave original data if there was any. 112 | success: false, 113 | code: ir.code || "ALLSERVER_CLIENT_INTROSPECTION_FAILED", 114 | message: 115 | ir.message || `Can't call '${procedureName}' procedure, introspection failed`, 116 | }; 117 | } else { 118 | return { 119 | success: false, 120 | code: "ALLSERVER_CLIENT_PROCEDURE_NOT_FOUND", 121 | message: `Procedure '${procedureName}' not found via introspection`, 122 | }; 123 | } 124 | } 125 | 126 | // Server is still reachable. It's just introspection didn't work, so let's call server side. 127 | // 🤞 128 | return allserverClient.call.call(proxyClient, procedureName, ...args); 129 | } 130 | } 131 | }; 132 | }, 133 | }); 134 | } 135 | 136 | module.exports = require("stampit")({ 137 | name: "AllserverClient", 138 | 139 | deepProps: { 140 | // Protected variables 141 | [p]: { 142 | // The protocol implementation strategy. 143 | transport: null, 144 | // The maximum time to wait until returning the ALLSERVER_CLIENT_TIMEOUT error. 0 means - no timeout. 145 | timeout: 60_000, 146 | // Disable any exception throwing when calling any methods. Otherwise, throws network and server errors. 147 | neverThrow: true, 148 | // Automatically find (introspect) and call corresponding remote procedures. Use only the methods defined in client side. 149 | dynamicMethods: true, 150 | // Try automatically fetch and assign methods to this client object. 151 | autoIntrospect: true, 152 | // If introspection couldn't find that procedure do not attempt sending a "call of faith" to the server. 153 | callIntrospectedProceduresOnly: true, 154 | // Map/filter procedure names from server names to something else. 155 | nameMapper: null, 156 | // 'before' middlewares. Invoked before calling server procedure. 157 | before: [], 158 | // 'after' middlewares. Invoked after calling server procedure. 159 | after: [], 160 | }, 161 | }, 162 | 163 | deepConf: { 164 | // prettier-ignore 165 | transports: { 166 | http() { return require("./HttpClientTransport"); }, 167 | https() { return require("./HttpClientTransport"); }, 168 | grpc() { return require("./GrpcClientTransport"); }, 169 | bullmq() { return require("./BullmqClientTransport"); }, 170 | lambda() { return require("./LambdaClientTransport"); }, 171 | }, 172 | }, 173 | 174 | init( 175 | { 176 | uri, 177 | transport, 178 | timeout, 179 | neverThrow, 180 | dynamicMethods, 181 | autoIntrospect, 182 | callIntrospectedProceduresOnly, 183 | nameMapper, 184 | before, 185 | after, 186 | }, 187 | { stamp } 188 | ) { 189 | this[p].timeout = timeout != null ? timeout : this[p].timeout; 190 | this[p].neverThrow = neverThrow != null ? neverThrow : this[p].neverThrow; 191 | this[p].dynamicMethods = dynamicMethods != null ? dynamicMethods : this[p].dynamicMethods; 192 | this[p].autoIntrospect = autoIntrospect != null ? autoIntrospect : this[p].autoIntrospect; 193 | this[p].callIntrospectedProceduresOnly = 194 | callIntrospectedProceduresOnly != null 195 | ? callIntrospectedProceduresOnly 196 | : this[p].callIntrospectedProceduresOnly; 197 | this[p].nameMapper = nameMapper != null ? nameMapper : this[p].nameMapper; 198 | 199 | this[p].transport = transport || this[p].transport; 200 | if (!this[p].transport) { 201 | if (!isString(uri)) throw new Error("`uri` connection string is required"); 202 | const schema = uri.substr(0, uri.indexOf("://")); 203 | if (!schema) throw new Error("`uri` must follow pattern: SCHEMA://URI"); 204 | const getTransport = stamp.compose.deepConfiguration.transports[schema.toLowerCase()]; 205 | if (!getTransport) throw new Error(`Schema not supported: ${uri}`); 206 | 207 | this[p].transport = getTransport()({ uri, timeout: this[p].timeout }); 208 | } 209 | 210 | if (before) this[p].before = [].concat(this[p].before).concat(before).filter(isFunction); 211 | if (after) this[p].after = [].concat(this[p].after).concat(after).filter(isFunction); 212 | }, 213 | 214 | methods: { 215 | async introspect() { 216 | const transport = this[p].transport; 217 | const defaultCtx = { client: this, isIntrospection: true }; 218 | const ctx = transport.createCallContext(defaultCtx); 219 | 220 | await this._callMiddlewares(ctx, "before", async () => { 221 | if (!ctx.result) { 222 | try { 223 | // This is supposed to be executed only once (per uri) unless it throws. 224 | // There are only 3 situations when this throws: 225 | // * the "introspect" method not found on server, (GRPC) 226 | // * the network request is malformed, (HTTP-like) 227 | // * couldn't connect to the remote host. 228 | ctx.result = await this._callTransport(ctx); 229 | } catch (err) { 230 | ctx.result = { 231 | success: false, 232 | code: "ALLSERVER_CLIENT_INTROSPECTION_FAILED", 233 | message: `Couldn't introspect ${transport.uri} due to: ${err.message}`, 234 | noNetToServer: Boolean(err.noNetToServer), 235 | error: err, 236 | }; 237 | } 238 | } 239 | 240 | await this._callMiddlewares(ctx, "after"); 241 | }); 242 | 243 | return ctx.result; 244 | }, 245 | 246 | async _callMiddlewares(ctx, middlewareType, next) { 247 | const runMiddlewares = async (middlewares) => { 248 | if (!middlewares?.length) { 249 | // no middlewares to run 250 | if (next) return await next(); 251 | return; 252 | } 253 | const middleware = middlewares[0]; 254 | async function handleMiddlewareResult(result) { 255 | if (result !== undefined) { 256 | ctx.result = result; 257 | // Do not call any more middlewares 258 | } else { 259 | await runMiddlewares(middlewares.slice(1)); 260 | } 261 | } 262 | try { 263 | if (middleware.length > 1) { 264 | // This middleware accepts more than one argument 265 | await middleware.call(this, ctx, handleMiddlewareResult); 266 | } else { 267 | const result = await middleware.call(this, ctx); 268 | await handleMiddlewareResult(result); 269 | } 270 | } catch (err) { 271 | if (!this[p].neverThrow) throw err; 272 | 273 | let { code, message } = err; 274 | if (!code) { 275 | code = "ALLSERVER_CLIENT_MIDDLEWARE_ERROR"; 276 | message = `The '${middlewareType}' middleware error while calling '${ctx.procedureName}' procedure: ${err.message}`; 277 | } 278 | ctx.result = { success: false, code, message, error: err }; 279 | // Do not call any more middlewares 280 | if (next) return await next(); 281 | } 282 | }; 283 | 284 | const middlewares = [].concat(this[p][middlewareType]).filter(isFunction); 285 | return await runMiddlewares(middlewares); 286 | }, 287 | 288 | _callTransport(ctx) { 289 | const transportMethod = ctx.isIntrospection ? "introspect" : "call"; 290 | // In JavaScript if the `timeout` is null or undefined or some other object this condition will return `false` 291 | if (this[p].timeout > 0) { 292 | let timeout = this[p].timeout; 293 | // Let's give a chance to the Transport to return its native timeout response before returning Client's timeout response. 294 | if (timeout >= 100) timeout = Math.round(timeout / 100 + timeout); 295 | return Promise.race([ 296 | this[p].transport[transportMethod](ctx), 297 | new Promise((resolve) => 298 | setTimeout( 299 | () => 300 | resolve({ 301 | success: false, 302 | code: "ALLSERVER_CLIENT_TIMEOUT", 303 | message: `The remote procedure ${ctx.procedureName} timed out in ${this[p].timeout} ms`, 304 | }), 305 | timeout 306 | ) 307 | ), 308 | ]); 309 | } 310 | 311 | return this[p].transport[transportMethod](ctx); 312 | }, 313 | 314 | async call(procedureName, arg) { 315 | if (!arg) arg = {}; 316 | if (!arg._) arg._ = {}; 317 | arg._.procedureName = procedureName; 318 | 319 | const defaultCtx = { procedureName, arg, client: this }; 320 | const ctx = this[p].transport.createCallContext(defaultCtx); 321 | 322 | await this._callMiddlewares(ctx, "before", async () => { 323 | if (!ctx.result) { 324 | try { 325 | ctx.result = await this._callTransport(ctx); 326 | } catch (err) { 327 | if (!this[p].neverThrow) throw err; 328 | 329 | let { code, message } = err; 330 | if (!err.code || err.noNetToServer) { 331 | code = "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE"; 332 | message = `Couldn't reach remote procedure ${ctx.procedureName} due to: ${err.message}`; 333 | } 334 | ctx.result = { success: false, code, message, error: err }; 335 | } 336 | } 337 | 338 | await this._callMiddlewares(ctx, "after"); 339 | }); 340 | 341 | return ctx.result; 342 | }, 343 | }, 344 | 345 | composers({ stamp }) { 346 | const { initializers, methods } = stamp.compose; 347 | // Keep our initializer the last to return proxy object instead of the client object. 348 | const index = initializers.indexOf(proxyWrappingInitialiser); 349 | if (index >= 0) initializers.splice(index, 1); 350 | initializers.push(proxyWrappingInitialiser); 351 | 352 | // We keep this cache in __proto__ because we want only one introspection call per multiple client objects made from this factory 353 | methods._introspectionCache = new Map(); 354 | }, 355 | 356 | statics: { 357 | defaults({ 358 | transport, 359 | timeout, 360 | neverThrow, 361 | dynamicMethods, 362 | autoIntrospect, 363 | callIntrospectedProceduresOnly, 364 | nameMapper, 365 | before, 366 | after, 367 | } = {}) { 368 | if (before != null) before = (Array.isArray(before) ? before : [before]).filter(isFunction); 369 | if (after != null) after = (Array.isArray(after) ? after : [after]).filter(isFunction); 370 | 371 | return this.deepProps({ 372 | [p]: { 373 | transport, 374 | timeout, 375 | neverThrow, 376 | dynamicMethods, 377 | callIntrospectedProceduresOnly, 378 | autoIntrospect, 379 | nameMapper, 380 | before, 381 | after, 382 | }, 383 | }); 384 | }, 385 | 386 | addTransport({ schema, Transport }) { 387 | return this.deepConf({ transports: { [schema.toLowerCase()]: () => Transport } }); 388 | }, 389 | }, 390 | }); 391 | -------------------------------------------------------------------------------- /test/integration/integration.test.js: -------------------------------------------------------------------------------- 1 | const assert = require("node:assert/strict"); 2 | const { describe, it } = require("node:test"); 3 | 4 | const procedures = { 5 | sayHello({ name }) { 6 | // await new Promise((r) => setTimeout(r, 61000)); 7 | return "Hello " + name; 8 | }, 9 | introspection({ enable }, { allserver }) { 10 | allserver.introspection = Boolean(enable); 11 | }, 12 | async gate({ number }) { 13 | if (number === 0) return undefined; 14 | if (number === 1) return { length: 42 }; 15 | if (number === 2) return { name: "Golden Gate", lastVehicle: "0 seconds ago" }; 16 | return { success: false, code: "GATE_NOT_FOUND", message: `Gate ${number} was not found` }; 17 | }, 18 | getArg(arg) { 19 | return arg; 20 | }, 21 | throws() { 22 | this.ping.me(); 23 | }, 24 | throwsBadArgs(arg) { 25 | assert(arg.missingArg, "missingArg is mandatory"); 26 | }, 27 | createUser({ firstName, lastName }) { 28 | // call database ... 29 | return { success: true, code: "CREATED", user: { id: String(Math.random()).substr(2), firstName, lastName } }; 30 | }, 31 | forcedTimeout() { 32 | return new Promise((resolve) => setTimeout(resolve, 10)); // resolve in 10ms, which is longer than the timeout of 1ms 33 | }, 34 | }; 35 | 36 | async function callClientMethods(client) { 37 | let response; 38 | 39 | // twice in a row 40 | response = await client.sayHello({ name: "world" }); 41 | const expectedHello = { success: true, code: "SUCCESS", message: "Success", sayHello: "Hello world" }; 42 | assert.deepEqual(response, expectedHello); 43 | response = await client.sayHello({ name: "world" }); 44 | assert.deepEqual(response, expectedHello); 45 | 46 | // Introspection work 47 | response = await client.introspect({}); 48 | assert.deepEqual(Object.keys(procedures), Object.keys(JSON.parse(response.procedures))); 49 | // Turn off introspection 50 | response = await client.introspection({ enable: false }); 51 | assert.equal(response.success, true); 52 | // Introspection should not work now 53 | response = await client.introspect(); 54 | assert.equal(response.success, false); 55 | assert.equal(response.code, "ALLSERVER_CLIENT_INTROSPECTION_FAILED"); 56 | // Turn on introspection 57 | response = await client.introspection({ enable: true }); 58 | assert.equal(response.success, true); 59 | // Introspection should be back working 60 | response = await client.introspect(); 61 | assert.equal(response.success, true); 62 | assert.equal(response.code, "ALLSERVER_INTROSPECTION"); 63 | 64 | for (const number of [0, 1, 2, 3]) { 65 | response = await client.gate({ number }); 66 | assert.equal(response.success, number <= 2); 67 | } 68 | 69 | response = await client.throws({}); 70 | assert.equal(response.success, false); 71 | assert.equal(response.code, "ALLSERVER_PROCEDURE_ERROR"); 72 | assert(response.message.startsWith("'Cannot read ")); 73 | assert(response.message.endsWith(" error in 'throws' procedure")); 74 | 75 | response = await client.throwsBadArgs({}); 76 | assert.equal(response.success, false); 77 | assert.equal(response.code, "ERR_ASSERTION"); 78 | assert.equal(response.message, `'missingArg is mandatory' error in 'throwsBadArgs' procedure`); 79 | 80 | response = await client.unexist({}); 81 | assert.equal(response.success, false); 82 | assert.equal(response.code, "ALLSERVER_CLIENT_PROCEDURE_NOT_FOUND"); 83 | assert.equal(response.message, "Procedure 'unexist' not found via introspection"); 84 | 85 | client[Symbol.for("AllserverClient")].timeout = 1; 86 | response = await client.forcedTimeout({}); 87 | assert.equal(response.success, false); 88 | assert.equal(response.code, "ALLSERVER_CLIENT_TIMEOUT"); 89 | assert.equal(response.message, "The remote procedure forcedTimeout timed out in 1 ms"); 90 | client[Symbol.for("AllserverClient")].timeout = 0; 91 | } 92 | 93 | let { 94 | Allserver, 95 | HttpTransport, 96 | ExpressTransport, 97 | GrpcTransport, 98 | LambdaTransport, 99 | BullmqTransport, 100 | BullmqClientTransport, 101 | MemoryTransport, 102 | AllserverClient, 103 | GrpcClientTransport, 104 | LambdaClientTransport, 105 | } = require("../.."); 106 | Allserver = Allserver.props({ logger: { error() {} } }); // silence the servers console because we test error cases here 107 | 108 | describe("integration", function () { 109 | describe("http", () => { 110 | const fetch = globalThis.fetch; 111 | 112 | it("should behave with AllserverClient", async () => { 113 | const httpClient = AllserverClient({ uri: "http://localhost:40000" }); 114 | 115 | let response; 116 | response = await httpClient.sayHello({ name: "world" }); 117 | assert.equal(response.success, false); 118 | assert.equal(response.code, "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE"); 119 | assert.equal(response.message, "Couldn't reach remote procedure: sayHello"); 120 | if (response.error.cause) assert.equal(response.error.cause.code, "ECONNREFUSED"); 121 | else assert(response.error.message.includes("ECONNREFUSED")); 122 | 123 | const httpServer = Allserver({ procedures, transport: HttpTransport({ port: 40000 }) }); 124 | await httpServer.start(); 125 | 126 | await callClientMethods(httpClient); 127 | 128 | // HTTP-ony specific tests 129 | 130 | // Should return 400 131 | response = await httpClient.throwsBadArgs(); 132 | assert.equal(response.code, "ERR_ASSERTION"); 133 | assert.equal(response.error.status, 400); 134 | 135 | // Should return 404 136 | response = await httpClient.unexist(); 137 | assert.equal(response.code, "ALLSERVER_CLIENT_PROCEDURE_NOT_FOUND"); 138 | 139 | // Should return 500 140 | response = await httpClient.throws(); 141 | assert.equal(response.code, "ALLSERVER_PROCEDURE_ERROR"); 142 | assert.equal(response.error.status, 500); 143 | 144 | await httpServer.stop(); 145 | }); 146 | 147 | it("should behave with fetch", async () => { 148 | const httpServer = Allserver({ procedures, transport: HttpTransport({ port: 40001 }) }); 149 | await httpServer.start(); 150 | 151 | let response; 152 | 153 | response = await ( 154 | await fetch("http://localhost:40001/sayHello", { 155 | method: "POST", 156 | body: JSON.stringify({ name: "world" }), 157 | }) 158 | ).json(); 159 | const expectedHello = { success: true, code: "SUCCESS", message: "Success", sayHello: "Hello world" }; 160 | assert.deepEqual(response, expectedHello); 161 | 162 | // HTTP-ony specific tests 163 | 164 | // Should return 400 165 | response = await fetch("http://localhost:40001/sayHello", { 166 | method: "POST", 167 | body: Buffer.allocUnsafe(999), 168 | }); 169 | assert(!response.ok); 170 | assert.equal(response.status, 400); 171 | 172 | // Should call using GET with query params 173 | response = await fetch("http://localhost:40001/sayHello?name=world"); 174 | assert(response.ok); 175 | const body = await response.json(); 176 | assert.deepEqual(body, expectedHello); 177 | 178 | const httpClient = AllserverClient({ uri: "http://localhost:40001" }); 179 | await callClientMethods(httpClient); 180 | await httpServer.stop(); 181 | }); 182 | }); 183 | 184 | describe("express", () => { 185 | const express = require("express"); 186 | const fetch = globalThis.fetch; 187 | 188 | it("should behave with AllserverClient", async () => { 189 | const httpClient = AllserverClient({ uri: "http://localhost:40002/express/allserver" }); 190 | 191 | let response; 192 | response = await httpClient.sayHello({ name: "world" }); 193 | assert.equal(response.success, false); 194 | assert.equal(response.code, "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE"); 195 | assert.equal(response.message, "Couldn't reach remote procedure: sayHello"); 196 | if (response.error.cause) assert.equal(response.error.cause.code, "ECONNREFUSED"); 197 | else assert(response.error.message.includes("ECONNREFUSED")); 198 | 199 | const app = express(); 200 | const expressServer = Allserver({ procedures, transport: ExpressTransport() }); 201 | app.use("/express/allserver", expressServer.start()); 202 | let server; 203 | await new Promise((r, e) => (server = app.listen(40002, (err) => (err ? e(err) : r())))); 204 | 205 | await callClientMethods(httpClient); 206 | 207 | // HTTP-ony specific tests 208 | 209 | // Should return 400 210 | response = await httpClient.throwsBadArgs(); 211 | assert.equal(response.code, "ERR_ASSERTION"); 212 | assert.equal(response.error.status, 400); 213 | 214 | // Should return 404 215 | response = await httpClient.unexist(); 216 | assert.equal(response.code, "ALLSERVER_CLIENT_PROCEDURE_NOT_FOUND"); 217 | 218 | // Should return 500 219 | response = await httpClient.throws(); 220 | assert.equal(response.code, "ALLSERVER_PROCEDURE_ERROR"); 221 | assert.equal(response.error.status, 500); 222 | 223 | if (server.closeIdleConnections) server.closeIdleConnections(); 224 | await server.close(); 225 | }); 226 | 227 | it("should behave with fetch", async () => { 228 | const app = express(); 229 | const expressServer = Allserver({ procedures, transport: ExpressTransport() }); 230 | app.use("/express/allsever", expressServer.start()); 231 | let server; 232 | await new Promise((r, e) => (server = app.listen(40003, (err) => (err ? e(err) : r())))); 233 | 234 | let response; 235 | 236 | response = await ( 237 | await fetch("http://localhost:40003/express/allsever/sayHello", { 238 | method: "POST", 239 | headers: { "Content-Type": "application/json" }, 240 | body: JSON.stringify({ name: "world" }), 241 | }) 242 | ).json(); 243 | const expectedHello = { success: true, code: "SUCCESS", message: "Success", sayHello: "Hello world" }; 244 | assert.deepEqual(response, expectedHello); 245 | 246 | // HTTP-ony specific tests 247 | 248 | // Should return 400 249 | response = await fetch("http://localhost:40003/express/allsever/sayHello", { 250 | method: "POST", 251 | headers: { "Content-Type": "application/json" }, 252 | body: Buffer.allocUnsafe(999), 253 | }); 254 | assert(!response.ok); 255 | assert.equal(response.status, 400); 256 | 257 | // Should call using GET with query params 258 | response = await fetch("http://localhost:40003/express/allsever/sayHello?name=world"); 259 | assert(response.ok); 260 | const body = await response.json(); 261 | assert.deepEqual(body, expectedHello); 262 | 263 | const httpClient = AllserverClient({ uri: "http://localhost:40003/express/allsever" }); 264 | await callClientMethods(httpClient); 265 | 266 | if (server.closeIdleConnections) server.closeIdleConnections(); 267 | await server.close(); 268 | }); 269 | }); 270 | 271 | describe("grpc", () => { 272 | const protoFile = __dirname + "/allserver_integration_test.proto"; 273 | 274 | it("should behave with AllserverClient", async () => { 275 | let response, grpcClient; 276 | 277 | // With protoFile 278 | grpcClient = AllserverClient({ 279 | transport: GrpcClientTransport({ protoFile, uri: "grpc://localhost:50051" }), 280 | }); 281 | response = await grpcClient.sayHello({ name: "world" }); 282 | assert.equal(response.success, false); 283 | assert.equal(response.code, "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE"); 284 | assert.equal(response.message, "Couldn't reach remote procedure: sayHello"); 285 | 286 | // Without protoFile 287 | grpcClient = AllserverClient({ 288 | transport: GrpcClientTransport({ uri: "grpc://localhost:50051" }), 289 | }); 290 | 291 | response = await grpcClient.sayHello({ name: "world" }); 292 | assert.equal(response.success, false); 293 | assert.equal(response.code, "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE"); 294 | assert.equal(response.message, "Couldn't reach remote procedure: sayHello"); 295 | 296 | const grpcServer = Allserver({ 297 | procedures, 298 | transport: GrpcTransport({ 299 | protoFile, 300 | port: 50051, 301 | options: {}, 302 | }), 303 | }); 304 | await grpcServer.start(); 305 | 306 | await callClientMethods(grpcClient); 307 | 308 | await grpcServer.stop(); 309 | }); 310 | 311 | it("should behave with @grpc/grpc-js", async () => { 312 | const grpcServer = Allserver({ 313 | procedures, 314 | transport: GrpcTransport({ protoFile, port: 50051 }), 315 | }); 316 | await grpcServer.start(); 317 | 318 | const grpc = require("@grpc/grpc-js"); 319 | const packageDefinition = require("@grpc/proto-loader").loadSync( 320 | __dirname + "/allserver_integration_test.proto" 321 | ); 322 | const proto = grpc.loadPackageDefinition(packageDefinition); 323 | const client = new proto.MyService("localhost:50051", grpc.credentials.createInsecure()); 324 | const { promisify } = require("node:util"); 325 | for (const k in client) if (typeof client[k] === "function") client[k] = promisify(client[k].bind(client)); 326 | 327 | const response = await client.sayHello({ name: "world" }); 328 | const expectedHello = { success: true, code: "SUCCESS", message: "Success", sayHello: "Hello world" }; 329 | assert.deepEqual(response, expectedHello); 330 | 331 | await grpcServer.stop(); 332 | }); 333 | }); 334 | 335 | describe("lambda", () => { 336 | const LocalLambdaClientTransport = require("./LocalLambdaClientTransport"); 337 | 338 | it("should behave using mocked client", async () => { 339 | const lambdaServer = Allserver({ 340 | procedures, 341 | transport: LambdaTransport(), 342 | }); 343 | 344 | const lambdaClient = AllserverClient({ 345 | transport: LocalLambdaClientTransport({ 346 | uri: "http://local/prefix", 347 | lambdaHandler: lambdaServer.start(), 348 | }), 349 | }); 350 | 351 | await callClientMethods(lambdaClient); 352 | }); 353 | 354 | it("should behave using mocked server", async () => { 355 | const lambdaServer = Allserver({ 356 | procedures, 357 | transport: LambdaTransport(), 358 | }); 359 | const lambdaHandler = lambdaServer.start(); 360 | 361 | const localLambda = require("lambda-local"); 362 | localLambda.setLogger({ log() {}, transports: [] }); // silence logs 363 | 364 | // Mocking the AWS SDK 365 | const MockedLambdaClientTransport = LambdaClientTransport.props({ 366 | awsSdkLambdaClient: { 367 | async invoke({ /*FunctionName,*/ Payload }) { 368 | const response = await localLambda.execute({ 369 | event: Payload && JSON.parse(Payload), 370 | lambdaFunc: { handler: lambdaHandler }, 371 | }); 372 | return { Payload: JSON.stringify(response) }; 373 | }, 374 | }, 375 | }); 376 | 377 | const lambdaClient = AllserverClient({ 378 | transport: MockedLambdaClientTransport({ uri: "my-lambda-name" }), 379 | }); 380 | 381 | await callClientMethods(lambdaClient); 382 | }); 383 | }); 384 | 385 | describe("memory", () => { 386 | it("should behave with AllserverClient", async () => { 387 | const memoryServer = Allserver({ 388 | procedures, 389 | transport: MemoryTransport(), 390 | }); 391 | 392 | const memoryClient = memoryServer.start(); 393 | 394 | await callClientMethods(memoryClient); 395 | }); 396 | }); 397 | 398 | describe("bullmq", () => { 399 | it("should behave with AllserverClient", async () => { 400 | let bullmqClient = AllserverClient({ uri: `bullmq://localhost:65432` }); // This port should not have Redis server on it. 401 | 402 | const response = await bullmqClient.sayHello({ name: "world" }); 403 | assert.equal(response.success, false); 404 | assert.equal(response.code, "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE"); 405 | assert.equal(response.message, "Couldn't reach remote procedure: sayHello"); 406 | assert.equal(response.error.code, "ECONNREFUSED"); 407 | 408 | if (process.platform === "win32") return; 409 | 410 | const port = 6379; 411 | const bullmqServer = Allserver({ 412 | procedures, 413 | transport: BullmqTransport({ connectionOptions: { host: "localhost", port } }), 414 | }); 415 | await bullmqServer.start(); 416 | 417 | bullmqClient = AllserverClient({ uri: `bullmq://localhost:${port}` }); 418 | await callClientMethods(bullmqClient); 419 | bullmqClient = AllserverClient({ 420 | transport: BullmqClientTransport({ uri: `redis://localhost:${port}` }), 421 | }); 422 | await callClientMethods(bullmqClient); 423 | 424 | await bullmqServer.stop(); 425 | }); 426 | 427 | it("should behave with 'bullmq' module", async () => { 428 | if (process.platform === "win32") return; 429 | 430 | const port = 6379; 431 | const bullmqServer = Allserver({ 432 | procedures, 433 | transport: BullmqTransport({ 434 | queueName: "OtherName", 435 | connectionOptions: { host: "localhost", port }, 436 | }), 437 | }); 438 | await bullmqServer.start(); 439 | 440 | const { Queue, QueueEvents } = require("bullmq"); 441 | 442 | const queue = new Queue("OtherName", { connection: { host: "localhost", port } }); 443 | queue.on("error", () => {}); // This line removes noisy console.error() output which is emitted while Redis is starting 444 | const queueEvents = new QueueEvents("OtherName", { connection: { host: "localhost", port } }); 445 | queueEvents.on("error", () => {}); // This line removes noisy console.error() output which is emitted while Redis is starting 446 | const jobsOptions = { removeOnComplete: true, removeOnFail: true }; 447 | const job = await queue.add("sayHello", { name: "world" }, jobsOptions); 448 | const response = await job.waitUntilFinished(queueEvents, 1000); 449 | const expectedHello = { success: true, code: "SUCCESS", message: "Success", sayHello: "Hello world" }; 450 | assert.deepEqual(response, expectedHello); 451 | 452 | await bullmqServer.stop(); 453 | }); 454 | }); 455 | }); 456 | -------------------------------------------------------------------------------- /test/server/Allserver.test.js: -------------------------------------------------------------------------------- 1 | const assert = require("node:assert/strict"); 2 | const { describe, it } = require("node:test"); 3 | 4 | const cls = require("cls-hooked"); 5 | const spaceName = "allserver"; 6 | const session = cls.getNamespace(spaceName) || cls.createNamespace(spaceName); 7 | function getTraceId() { 8 | if (session?.active) { 9 | return session.get("traceId") || ""; 10 | } 11 | 12 | return ""; 13 | } 14 | function setTraceIdAndRunFunction(traceId, func, ...args) { 15 | return new Promise((resolve, reject) => { 16 | session.run(async () => { 17 | session.set("traceId", traceId); 18 | 19 | try { 20 | const result = await func(...args); 21 | resolve(result); 22 | } catch (err) { 23 | reject(err); 24 | } 25 | }); 26 | }); 27 | } 28 | 29 | const VoidTransport = require("stampit")({ 30 | methods: { 31 | isIntrospection: () => false, 32 | getProcedureName: (ctx) => ctx.void.proc, 33 | prepareIntrospectionReply() {}, 34 | prepareNotFoundReply() {}, 35 | prepareProcedureErrorReply() {}, 36 | reply() {}, 37 | }, 38 | }); 39 | const Allserver = require("../..").Allserver.props({ 40 | transport: VoidTransport(), 41 | procedures: { testMethod() {} }, 42 | }); 43 | 44 | describe("Allserver", () => { 45 | describe("#handleCall", () => { 46 | it("should count calls", () => { 47 | const server = Allserver(); 48 | 49 | let ctx = { void: { proc: "testMethod" } }; 50 | server.handleCall(ctx); 51 | assert.equal(ctx.callNumber, 0); 52 | ctx = { void: { proc: "testMethod" } }; 53 | server.handleCall(ctx); 54 | assert.equal(ctx.callNumber, 1); 55 | }); 56 | 57 | it("should call if procedures is an array", async () => { 58 | let called = false; 59 | const server = Allserver({ 60 | procedures: [ 61 | () => { 62 | called = true; 63 | return 42; 64 | }, 65 | ], 66 | }); 67 | 68 | let ctx = { void: { proc: "0" } }; 69 | await server.handleCall(ctx); 70 | assert.equal(called, true); 71 | }); 72 | 73 | it("should reply ALLSERVER_PROCEDURE_NOT_FOUND", async () => { 74 | let replied = false; 75 | const MockedTransport = VoidTransport.methods({ 76 | prepareNotFoundReply() { 77 | ctx.result = { 78 | success: false, 79 | code: "ALLSERVER_PROCEDURE_NOT_FOUND", 80 | message: `Procedure not found: ${ctx.procedureName}`, 81 | }; 82 | }, 83 | reply(ctx) { 84 | assert.deepEqual(ctx.result, { 85 | success: false, 86 | code: "ALLSERVER_PROCEDURE_NOT_FOUND", 87 | message: "Procedure not found: dontExist", 88 | }); 89 | replied = true; 90 | }, 91 | }); 92 | const server = Allserver({ 93 | procedures: { foo() {}, bar() {} }, 94 | transport: MockedTransport(), 95 | }); 96 | 97 | const ctx = { void: { proc: "dontExist" } }; 98 | await server.handleCall(ctx); 99 | assert(replied); 100 | }); 101 | 102 | it("should call the designated procedure", async () => { 103 | let replied = false; 104 | const MockedTransport = VoidTransport.methods({ 105 | reply(ctx) { 106 | assert.deepEqual(ctx.result, { 107 | success: true, 108 | code: "OPENED", 109 | message: "Bla", 110 | data: "point", 111 | }); 112 | replied = true; 113 | }, 114 | }); 115 | const server = Allserver({ 116 | procedures: { 117 | foo({ bar }) { 118 | assert.equal(bar, "baz"); 119 | return { success: true, code: "OPENED", message: "Bla", data: "point" }; 120 | }, 121 | }, 122 | transport: MockedTransport(), 123 | }); 124 | 125 | const ctx = { void: { proc: "foo" }, arg: { bar: "baz" } }; 126 | await server.handleCall(ctx); 127 | assert(replied); 128 | }); 129 | 130 | it("should reply ALLSERVER_PROCEDURE_ERROR", async () => { 131 | let replied = false; 132 | const MockedTransport = VoidTransport.methods({ 133 | reply(ctx) { 134 | assert.deepEqual(ctx.result, { 135 | success: false, 136 | code: "ALLSERVER_PROCEDURE_ERROR", 137 | message: "''undefined' is not a function' error in 'foo' procedure", 138 | }); 139 | replied = true; 140 | }, 141 | }); 142 | let logged = false; 143 | const server = Allserver({ 144 | logger: { 145 | error(err, code) { 146 | assert.equal(code, "ALLSERVER_PROCEDURE_ERROR"); 147 | assert.equal(err.message, "'undefined' is not a function"); 148 | logged = true; 149 | }, 150 | }, 151 | procedures: { 152 | async foo() { 153 | throw new TypeError("'undefined' is not a function"); 154 | }, 155 | }, 156 | transport: MockedTransport(), 157 | }); 158 | 159 | await server.handleCall({ void: { proc: "foo" } }); 160 | 161 | assert(replied); 162 | assert(logged); 163 | }); 164 | 165 | it("should solidify returned object", async () => { 166 | const returns = [undefined, null, 0, "", {}, "Hui", []]; 167 | 168 | for (const value of returns) { 169 | const server = Allserver({ procedures: { testMethod: () => value } }); 170 | 171 | const ctx = { void: { proc: "testMethod" } }; 172 | await server.handleCall(ctx); 173 | 174 | assert.equal(typeof ctx.result, "object"); 175 | assert.equal(typeof ctx.result.success, "boolean"); 176 | assert.equal(typeof ctx.result.code, "string"); 177 | assert.equal(typeof ctx.result.message, "string"); 178 | } 179 | }); 180 | }); 181 | 182 | describe("#start", () => { 183 | it("should call transport startServer()", async () => { 184 | let started = false; 185 | const MockedTransport = VoidTransport.methods({ 186 | async startServer(...args) { 187 | assert.equal(args.length, 1); 188 | assert.equal(args[0].allserver, server); 189 | started = true; 190 | }, 191 | }); 192 | 193 | const server = Allserver({ transport: MockedTransport() }); 194 | const promise = server.start(); 195 | 196 | assert(promise.then); 197 | await promise; 198 | assert(started); 199 | }); 200 | }); 201 | 202 | describe("#stop", () => { 203 | it("should call transport stopServer()", async () => { 204 | let stopped = false; 205 | const MockedTransport = VoidTransport.methods({ 206 | async stopServer() { 207 | stopped = true; 208 | }, 209 | }); 210 | 211 | const server = Allserver({ transport: MockedTransport() }); 212 | const promise = server.stop(); 213 | 214 | assert(promise.then); 215 | await promise; 216 | assert(stopped); 217 | }); 218 | }); 219 | 220 | describe("introspection", () => { 221 | it("should disable introspection", async () => { 222 | let replied = false; 223 | const MockedTransport = VoidTransport.methods({ 224 | isIntrospection: () => true, 225 | async prepareIntrospectionReply(ctx) { 226 | assert(!ctx.introspection); 227 | }, 228 | async reply(ctx) { 229 | assert.equal(ctx.result, undefined); 230 | replied = true; 231 | }, 232 | }); 233 | const server = Allserver({ introspection: false, transport: MockedTransport() }); 234 | 235 | let ctx = { void: { proc: "" } }; 236 | await server.handleCall(ctx); 237 | 238 | assert(replied); 239 | }); 240 | 241 | it("should disable introspection via function", async () => { 242 | let replied = false; 243 | const MockedTransport = VoidTransport.methods({ 244 | isIntrospection: () => true, 245 | async prepareIntrospectionReply(ctx) { 246 | assert(!ctx.introspection); 247 | }, 248 | async reply(ctx) { 249 | assert.equal(ctx.result, undefined); 250 | replied = true; 251 | }, 252 | }); 253 | const server = Allserver({ introspection: () => false, transport: MockedTransport() }); 254 | 255 | let ctx = { void: { proc: "" } }; 256 | await server.handleCall(ctx); 257 | 258 | assert(replied); 259 | }); 260 | 261 | it("should introspect", async () => { 262 | let replied = false; 263 | const MockedTransport = VoidTransport.methods({ 264 | isIntrospection: () => true, 265 | getProcedureName(ctx) { 266 | assert.equal(ctx.void.proc, "introspect"); 267 | return ""; 268 | }, 269 | prepareIntrospectionReply(ctx) { 270 | ctx.result = { 271 | success: true, 272 | code: "ALLSERVER_INTROSPECTION", 273 | message: "Introspection as JSON string", 274 | procedures: ctx.introspection, 275 | }; 276 | }, 277 | reply(ctx) { 278 | assert.deepEqual(ctx.introspection, { foo: "function", bar: "function" }); 279 | replied = true; 280 | }, 281 | }); 282 | const server = Allserver({ 283 | procedures: { foo() {}, bar() {} }, 284 | transport: MockedTransport(), 285 | }); 286 | 287 | const ctx = { void: { proc: "introspect" } }; 288 | await server.handleCall(ctx); 289 | assert(replied); 290 | }); 291 | }); 292 | 293 | describe("middleware", () => { 294 | describe("'before'", () => { 295 | it("should call 'before'", async () => { 296 | let called = false; 297 | const server = Allserver({ 298 | before(ctx) { 299 | assert.equal(this, server, "The `this` context must be the server itself"); 300 | assert.deepEqual(ctx, { 301 | arg: { _: { procedureName: "testMethod" } }, 302 | callNumber: 0, 303 | procedure: server.procedures.testMethod, 304 | procedureName: "testMethod", 305 | isIntrospection: false, 306 | void: { 307 | proc: "testMethod", 308 | }, 309 | }); 310 | called = true; 311 | }, 312 | }); 313 | 314 | let ctx = { void: { proc: "testMethod" } }; 315 | await server.handleCall(ctx); 316 | 317 | assert(called); 318 | }); 319 | 320 | it("should handle exceptions from 'before'", async () => { 321 | let logged = false; 322 | let lastMiddlewareCalled = false; 323 | const server = Allserver({ 324 | logger: { 325 | error(err, code) { 326 | assert.equal(code, "ALLSERVER_MIDDLEWARE_ERROR"); 327 | assert.equal(err.message, "Handle me please"); 328 | logged = true; 329 | }, 330 | }, 331 | before: [ 332 | () => { 333 | throw new Error("Handle me please"); 334 | }, 335 | () => { 336 | lastMiddlewareCalled = true; 337 | }, 338 | ], 339 | }); 340 | 341 | let ctx = { void: { proc: "testMethod" } }; 342 | await server.handleCall(ctx); 343 | 344 | assert(logged); 345 | assert.equal(lastMiddlewareCalled, false); 346 | assert.deepEqual(ctx.result, { 347 | success: false, 348 | code: "ALLSERVER_MIDDLEWARE_ERROR", 349 | message: "'Handle me please' error in 'before' middleware", 350 | }); 351 | }); 352 | 353 | it("should allow multiple 'before'", async () => { 354 | let called = []; 355 | const server = Allserver({ 356 | before: [ 357 | () => { 358 | called.push(1); 359 | return undefined; 360 | }, 361 | () => { 362 | called.push(2); 363 | return { success: false, code: "BAD_AUTH_OR_SOMETHING", message: "Bad auth or something" }; 364 | }, 365 | () => { 366 | called.push(3); 367 | assert.fail("should not be called"); 368 | }, 369 | ], 370 | }); 371 | 372 | let ctx = { void: { proc: "testMethod" } }; 373 | await server.handleCall(ctx); 374 | 375 | assert.deepEqual(called, [1, 2]); 376 | assert.deepEqual(ctx.result, { 377 | success: false, 378 | code: "BAD_AUTH_OR_SOMETHING", 379 | message: "Bad auth or something", 380 | }); 381 | }); 382 | 383 | it("should return 'before' result", async () => { 384 | let replied = false; 385 | const MockedTransport = VoidTransport.methods({ 386 | async reply(ctx) { 387 | assert.equal(ctx.result, 42); 388 | replied = true; 389 | }, 390 | }); 391 | const server = Allserver({ 392 | transport: MockedTransport(), 393 | before() { 394 | // There must be no result yet 395 | assert(!ctx.result); 396 | return 42; 397 | }, 398 | }); 399 | 400 | let ctx = { void: { proc: "testMethod" } }; 401 | await server.handleCall(ctx); 402 | 403 | assert(replied); 404 | }); 405 | 406 | it("should not call procedure if 'before' throws", async () => { 407 | let replied = false; 408 | let procCalled = false; 409 | const MockedTransport = VoidTransport.methods({ 410 | async reply() { 411 | replied = true; 412 | }, 413 | }); 414 | const server = Allserver({ 415 | logger: { error() {} }, 416 | transport: MockedTransport(), 417 | before() { 418 | throw new Error("Handle me please"); 419 | }, 420 | procedures: { 421 | testMethod() { 422 | procCalled = true; 423 | assert.fail("should not be called"); 424 | }, 425 | }, 426 | }); 427 | 428 | let ctx = { void: { proc: "testMethod" } }; 429 | await server.handleCall(ctx); 430 | 431 | assert(!procCalled); 432 | assert(replied); 433 | }); 434 | 435 | it("should preserve async_hooks context in 'before'", async () => { 436 | let called = []; 437 | const server = Allserver({ 438 | before: [ 439 | (ctx, next) => { 440 | const traceId = ctx.arg._.traceId; 441 | if (traceId) { 442 | setTraceIdAndRunFunction(traceId, next); 443 | } else { 444 | next(); 445 | } 446 | }, 447 | () => { 448 | assert.equal(getTraceId(), "my-random-trace-id"); 449 | called.push(1); 450 | return undefined; 451 | }, 452 | () => { 453 | assert.equal(getTraceId(), "my-random-trace-id"); 454 | called.push(2); 455 | return { success: false, code: "BAD_AUTH_OR_SOMETHING", message: "Bad auth or something" }; 456 | }, 457 | () => { 458 | called.push(3); 459 | assert.fail("should not be called"); 460 | }, 461 | ], 462 | }); 463 | 464 | let ctx = { void: { proc: "testMethod" }, arg: { _: { traceId: "my-random-trace-id" } } }; 465 | await server.handleCall(ctx); 466 | 467 | assert.deepEqual(called, [1, 2]); 468 | assert.deepEqual(ctx.result, { 469 | success: false, 470 | code: "BAD_AUTH_OR_SOMETHING", 471 | message: "Bad auth or something", 472 | }); 473 | }); 474 | }); 475 | 476 | describe("'after'", () => { 477 | it("should call 'after'", async () => { 478 | let called = false; 479 | const server = Allserver({ 480 | after(ctx) { 481 | assert.equal(this, server, "The `this` context must be the server itself"); 482 | assert.deepEqual(ctx, { 483 | arg: { _: { procedureName: "testMethod" } }, 484 | callNumber: 0, 485 | procedure: server.procedures.testMethod, 486 | procedureName: "testMethod", 487 | isIntrospection: false, 488 | result: { 489 | code: "SUCCESS", 490 | message: "Success", 491 | success: true, 492 | }, 493 | void: { 494 | proc: "testMethod", 495 | }, 496 | }); 497 | called = true; 498 | }, 499 | }); 500 | 501 | let ctx = { void: { proc: "testMethod" } }; 502 | await server.handleCall(ctx); 503 | 504 | assert(called); 505 | }); 506 | 507 | it("should handle exceptions from 'before'", async () => { 508 | let logged = false; 509 | const server = Allserver({ 510 | logger: { 511 | error(err, code) { 512 | assert.equal(code, "ALLSERVER_MIDDLEWARE_ERROR"); 513 | assert.equal(err.message, "Handle me please"); 514 | logged = true; 515 | }, 516 | }, 517 | after() { 518 | throw new Error("Handle me please"); 519 | }, 520 | }); 521 | 522 | let ctx = { void: { proc: "testMethod" } }; 523 | await server.handleCall(ctx); 524 | 525 | assert(logged); 526 | assert.deepEqual(ctx.result, { 527 | success: false, 528 | code: "ALLSERVER_MIDDLEWARE_ERROR", 529 | message: "'Handle me please' error in 'after' middleware", 530 | }); 531 | }); 532 | 533 | it("should allow multiple 'after'", async () => { 534 | let called = []; 535 | const server = Allserver({ 536 | after: [ 537 | () => { 538 | called.push(1); 539 | return undefined; 540 | }, 541 | () => { 542 | called.push(2); 543 | return { success: false, code: "OVERWRITING_RESULT", message: "Something something" }; 544 | }, 545 | () => { 546 | called.push(3); 547 | assert.fail("Should not be called"); 548 | }, 549 | ], 550 | }); 551 | 552 | let ctx = { void: { proc: "testMethod" } }; 553 | await server.handleCall(ctx); 554 | 555 | assert.deepEqual(called, [1, 2]); 556 | assert.deepEqual(ctx.result, { 557 | success: false, 558 | code: "OVERWRITING_RESULT", 559 | message: "Something something", 560 | }); 561 | }); 562 | 563 | it("should return 'after' result", async () => { 564 | let replied = false; 565 | const MockedTransport = VoidTransport.methods({ 566 | async reply(ctx) { 567 | assert.equal(ctx.result, 42); 568 | replied = true; 569 | }, 570 | }); 571 | const server = Allserver({ 572 | transport: MockedTransport(), 573 | after(ctx) { 574 | // There already must be a result 575 | assert(ctx.result); 576 | return 42; 577 | }, 578 | }); 579 | 580 | let ctx = { void: { proc: "testMethod" } }; 581 | await server.handleCall(ctx); 582 | 583 | assert(replied); 584 | }); 585 | 586 | it("should preserve async_hooks context in 'after'", async () => { 587 | let called = []; 588 | const server = Allserver({ 589 | before: [ 590 | (ctx, next) => { 591 | const traceId = ctx.arg._.traceId; 592 | if (traceId) { 593 | setTraceIdAndRunFunction(traceId, next); 594 | } else { 595 | next(); 596 | } 597 | }, 598 | ], 599 | procedures: { 600 | testMethod() { 601 | assert.equal(getTraceId(), "my-random-trace-id"); 602 | called.push("testMethod"); 603 | }, 604 | }, 605 | after: [ 606 | () => { 607 | assert.equal(getTraceId(), "my-random-trace-id"); 608 | called.push(1); 609 | }, 610 | () => { 611 | assert.equal(getTraceId(), "my-random-trace-id"); 612 | called.push(2); 613 | }, 614 | ], 615 | }); 616 | 617 | let ctx = { void: { proc: "testMethod" }, arg: { _: { traceId: "my-random-trace-id" } } }; 618 | await server.handleCall(ctx); 619 | 620 | assert.deepEqual(called, ["testMethod", 1, 2]); 621 | assert.deepEqual(ctx.result, { 622 | success: true, 623 | code: "SUCCESS", 624 | message: "Success", 625 | }); 626 | }); 627 | }); 628 | 629 | describe("'before'+'after'", () => { 630 | it("should call 'after' even if 'before' throws", async () => { 631 | let afterCalled = false; 632 | const err = new Error("Bad middleware"); 633 | const server = Allserver({ 634 | logger: { error() {} }, 635 | before() { 636 | throw err; 637 | }, 638 | after() { 639 | afterCalled = true; 640 | }, 641 | }); 642 | 643 | let ctx = { void: { proc: "testMethod" } }; 644 | await server.handleCall(ctx); 645 | 646 | assert.deepEqual(ctx.result, { 647 | success: false, 648 | code: "ALLSERVER_MIDDLEWARE_ERROR", 649 | message: "'Bad middleware' error in 'before' middleware", 650 | }); 651 | assert.equal(ctx.error, err); 652 | assert(afterCalled); 653 | }); 654 | }); 655 | }); 656 | 657 | describe("init", () => { 658 | it("should init", () => { 659 | const server = require("../..").Allserver(); 660 | 661 | // Creates HttpTransport by default 662 | assert(server.transport.micro); 663 | 664 | // Users console logger by default 665 | assert.equal(server.logger, console); 666 | }); 667 | }); 668 | 669 | describe("defaults", () => { 670 | it("should work", () => { 671 | const procedures = {}; 672 | const transport = VoidTransport(); 673 | const logger = {}; 674 | const introspection = false; 675 | const before = () => {}; 676 | const after = () => {}; 677 | 678 | const NewServer = Allserver.defaults({ procedures, transport, logger, introspection, before, after }); 679 | 680 | function propsAreOk(server) { 681 | assert.equal(server.procedures, procedures); 682 | assert.equal(server.transport, transport); 683 | assert.equal(server.logger, logger); 684 | assert.equal(server.introspection, introspection); 685 | assert.deepEqual(server.before, [before]); 686 | assert.deepEqual(server.after, [after]); 687 | } 688 | 689 | propsAreOk(NewServer()); 690 | }); 691 | 692 | it("should merge middlewares if supplied in the constructor too", () => { 693 | const procedures = {}; 694 | const transport = VoidTransport(); 695 | const logger = {}; 696 | const introspection = false; 697 | const before = () => {}; 698 | const before2 = () => {}; 699 | const after = () => {}; 700 | const after2 = () => {}; 701 | 702 | const NewServer = Allserver.defaults({ procedures, transport, logger, introspection, before, after }); 703 | 704 | function propsAreOk(props) { 705 | assert.equal(props.procedures, procedures); 706 | assert.equal(props.transport, transport); 707 | assert.equal(props.logger, logger); 708 | assert.equal(props.introspection, introspection); 709 | assert.deepEqual(props.before, [before, before2]); 710 | assert.deepEqual(props.after, [after, after2]); 711 | } 712 | 713 | propsAreOk(NewServer({ before: before2, after: after2 })); 714 | }); 715 | 716 | it("should create new factory", () => { 717 | assert.notEqual(Allserver, Allserver.defaults()); 718 | }); 719 | }); 720 | }); 721 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Allserver 2 | 3 | Multi-transport and multi-protocol simple RPC server and (optional) client. Boilerplate-less. Opinionated. Minimalistic. DX-first. 4 | 5 | Think of Remote Procedure Calls using exactly the same client and server code but via multiple protocols/mechanisms such as HTTP, gRPC, GraphQL, WebSockets, job queues, Lambda, inter-process, (Web)Workers, unix sockets, etc etc etc. 6 | 7 | Should be used in (micro)services where JavaScript is able to run - your computer, Docker, k8s, virtual machines, serverless functions (Lambdas, Google Cloud Functions, Azure Functions, etc), RaspberryPI, SharedWorker, thread, you name it. 8 | 9 | > Uber [moved](https://www.infoq.com/news/2023/10/uber-up-cloud-microservices/) most of its containerized microservices from µDeploy to a new multi-cloud platform named Up in preparation for migrating a considerable portion of its compute footprint to the cloud. **The company spent two years** working on making its many microservices portable to migrate between different computing infrastructures and container management platforms. 10 | 11 | Superpowers the `Allserver` gives you: 12 | 13 | - Move your code around infrastructures, deployment types, runtimes, platforms, etc with ease. 14 | - Call gRPC server methods from browser/curl/Postman. 15 | - Run your HTTP server as gRPC with a single line change (almost). 16 | - Serve same logic via HTTP and gRPC (or more) simultaneously in the same node.js process. 17 | - Deploy and run your HTTP server on AWS Lambda with no code changes. 18 | - Embed same exact code into your existing Express.js, no need to implement any handlers/middleware. 19 | - Use Redis-backed job queue [BullMQ](https://docs.bullmq.io) to call remote procedures reliably. 20 | - And moar! 21 | 22 | Superpowers the `AllserverClient` gives you: 23 | 24 | - (Optionally) Stop writing `try-catch` when calling a remote procedure. 25 | - Call remote procedures just like regular methods. 26 | - Call any transport/protocol server methods with exactly the same client-side code. 27 | - And moar! 28 | 29 |

30 | Evan You quote about "bad" ideas 31 |

32 | 33 | ##### Spelling 34 | 35 | "Allserver" is a single word, capital "A", lowercase "s". 36 | 37 | ### Quick example 38 | 39 | This is how your code would look like in all execution environments using any communication protocol out there. 40 | 41 | Server side: 42 | 43 | ```js 44 | const procedures = { 45 | sayHello: ({ name }) => "Hello " + name, 46 | }; 47 | require("allserver").Allserver({ procedures }).start(); 48 | ``` 49 | 50 | AllserverClient call: 51 | 52 | ```js 53 | const AllserverClient = require("allserver/Client"); 54 | const client = AllserverClient({ uri: process.env.REMOTE_SERVER_URI }); 55 | 56 | const { success, code, sayHello } = await client.sayHello({ name: "Joe" }); 57 | 58 | if (success) { 59 | // "Hello Joe" 60 | console.log(sayHello); 61 | } else { 62 | // something like "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE" 63 | console.error(code); 64 | } 65 | ``` 66 | 67 | Or use your **own client library**, e.g. `fetch` for HTTP or `@grpc/grpc-js` for gRPC. 68 | 69 | ## Why Allserver exists? 70 | 71 | There are loads of developer video presentations where people rant about how things are complex and how we must do extra effort to simplify things. Allserver is about simplicity and developer experience. 72 | 73 | Problems I had: 74 | 75 | - **Error handling while calling remote procedures are exhausting.** 76 | - Firstly, I wrap the HTTP call in a `try-catch`. Then, I always had to analise: the status code, then detect the response body content type - text or json, then analyse the body for the actual error. I got very much annoyed by this repetitive boilerplate code. 77 | - **REST route naming is not clear enough.** 78 | - What is this route for - `/user`? You need to read the docs! I want it to be as simple and clear as calling a JS function/method - `createUser()`, or `updateUser()`, or `getUser()`, or `removeUser()`, or `findUsers()`, etc. 79 | - **The HTTP methods are a pain point of any REST API.** 80 | - Is it `POST` or `PUT` or `PATCH`? What if a route removes a permission to a file from a user, should it be `DELETE` or `POST`? I know it should be `POST`, but it's confusing to call `POST` when I need to REMOVE something. Protocol-level methods are never enough. 81 | - **The HTTP status codes are never enough and overly confusing.** 82 | - If a `user` record in the database is readonly, and you are trying to modify it, a typical server would reply `400 Bad Request`. However, the request, the user data, the system are all perfectly fine. The HTTP statuses were not designed for you business logic errors. 83 | - **The GraphQL has great DX and tooling, but it's not a good fit for microservices.** 84 | - It adds too much complexity and is performance unfriendly (slow). Allserver is about simplicity, not complexity. 85 | - **Performance scaling** 86 | - When a performance scaling was needed I had to rewrite an entire service and client source code in multiple projects to a more performant network protocol implementation. This was a significant and avoidable time waste in my opinion. 87 | - **HTTP monitoring tools show business errors as alarms.** 88 | - I was trying to check if a user with email `bla@example.com` exists. REST reply was HTTP `404`. It alarmed our monitoring tools. Whereas, I don't want that alarm. That's not an error, but a regular true/false check. I want to monitor only the "route not found" errors with ease. 89 | 90 | When calling a remote procedure I want something which: 91 | 92 | - Does not throw exceptions client-side no matter what. **All** kinds of errors should be handled exactly the same way. 93 | - Can be easily mapped to any language, any protocol. Especially to upstream GraphQL mutations. 94 | - Is simple to read in the source code, just like a method/function call. Without thinking of protocol-level details for every damn call. 95 | - Allows me to test gRPC server from my browser/Postman/curl (via HTTP!) by a simple one line config change. 96 | - Replace flaky HTTP with Kafka 97 | - Does not bring tons of npm dependencies with it. 98 | 99 | Also, the main driving force was my vast experience splitting monolith servers onto (micro)services. Here is how I do it with much success. 100 | 101 | - Firstly, refactor a function to return object of the `{success,code,message,...}` shape, and to never throw. 102 | - Then, move the function over to a microservice. 103 | - Done. 104 | 105 | Ideas are taken from multiple places. 106 | 107 | - [slack.com HTTP RPC API](https://api.slack.com/web) - always return object of a same shape - `{ok:Boolean, error:String}`. 108 | - [GoLang error handling](https://blog.golang.org/error-handling-and-go) - always return two things from a function call - the result and the error. 109 | - [GraphQL introspection](https://graphql.org/learn/introspection/) - introspection API out of the box by default. 110 | 111 | ## Core principles 112 | 113 | 1. **Always choose DX (Developer Experience) over everything else** 114 | - Otherwise, Allserver won't differ from the alternatives. 115 | - When newbie developer reads server-side code of an RPC (micro)service they should quickly understand what is what. 116 | 1. **Switching between data protocols must be as easy as changing single config value** 117 | - Common example is when you want to convert your (micro)service from HTTP to gRPC. 118 | - Or if you want to call the gRPC server you are developing but don't have the gRPC client, so you use [Postman](https://www.postman.com/downloads/), `curl` or a browser (HTTP) for that. 119 | - Or you are moving the procedure/function/method to another (micro)service employing different communication protocol and infrastructure. E.g. when migrating from a monolith to serverless architecture. 120 | 1. **Calling procedures client side must be as easy as a regular function call** 121 | - `const { success, user } = await client.updateUser({ id: 622, firstName: "Hui" })` 122 | - Hence, the next core principle... 123 | 1. **Exceptions should be never thrown** 124 | - The object of the following shape must always be returned: `success,code,message,...`. 125 | - Although, this should be configurable (see `neverThrow: false`). 126 | 1. **Procedures (aka routes, aka methods, aka handlers) must always return same shaped interface regardless of everything** 127 | - This makes your (micro)service protocol agnostic. 128 | - HTTP server must always return `{success:Boolean, code:String, message:String}`. 129 | - Same for gRPC - `message Reply { bool success = 1; string code = 2; string message = 3; }`. 130 | - Same for GraphQL - `interface IAllserverReply { success:Boolean! code:String message:String }` 131 | - Same for other protocols/languages. 132 | 1. **Protocol-level things must NOT be used for business-level logic (i.e. no REST)** 133 | - This makes your (micro)service protocol agnostic. 134 | - E.g. the HTTP status codes must be used for protocol-level errors only. 135 | 1. **All procedures must accept single argument - JSON options object** 136 | - This makes your (micro)service protocol agnostic. 137 | 1. **Procedures introspection (aka programmatic discovery) should work out of the box** 138 | - This allows `AllserverClient` to know nothing about the remote server when your code starts to run. 139 | 140 | ## Usage 141 | 142 | Please note, that Allserver depends only on a single tiny npm module [`stampit`](https://stampit.js.org). Every other dependency is _optional_. 143 | 144 | ### HTTP protocol 145 | 146 | #### Server 147 | 148 | The default `HttpTransport` is using the [`micro`](http://npmjs.com/package/micro) npm module as an optional dependency. 149 | 150 | ```shell 151 | npm i allserver micro@10 152 | ``` 153 | 154 | Alternatively, you can embed Allserver into your existing Express.js application: 155 | 156 | ```shell 157 | npm i allserver express 158 | ``` 159 | 160 | #### Client 161 | 162 | Optionally, you can use Allserver's built-in client: 163 | 164 | ```shell 165 | npm i allserver 166 | ``` 167 | 168 | Or do HTTP requests using any module you like. 169 | 170 | ### AWS Lambda 171 | 172 | #### Server 173 | 174 | No dependencies other than `allserver` itself. 175 | 176 | ```shell 177 | npm i allserver 178 | ``` 179 | 180 | #### Client 181 | 182 | Two ways: 183 | 184 | - Same as the HTTP protocol client above. 185 | - Direct invocation via AWS SDK or AWS CLI. 186 | 187 | ### gRPC protocol 188 | 189 | #### Server 190 | 191 | The default `GrpcTransport` is using the standard the [`@grpc/grpc-js`](https://www.npmjs.com/package/@grpc/grpc-js) npm module as an optional dependency. 192 | 193 | ```shell 194 | npm i allserver @grpc/grpc-js@1 @grpc/proto-loader@0.5 195 | ``` 196 | 197 | Note, with gRPC server and client you'd need to have your own `.proto` file. See code example below. 198 | 199 | #### Client 200 | 201 | Optionally, you can use Allserver's built-in client: 202 | 203 | ```shell 204 | npm i allserver @grpc/grpc-js@1 @grpc/proto-loader@0.5 205 | ``` 206 | 207 | Or do gRPC requests using any module you like. 208 | 209 | ### [BullMQ](https://docs.bullmq.io) job queue 210 | 211 | #### Server 212 | 213 | The default `BullmqTransport` is using the [`bullmq`](https://www.npmjs.com/package/bullmq) module as a dependency, connects to Redis using `Worker` class. 214 | 215 | ```shell 216 | npm i allserver bullmq 217 | ``` 218 | 219 | #### Client 220 | 221 | Optionally, you can use Allserver's built-in client: 222 | 223 | ```shell 224 | npm i allserver bullmq 225 | ``` 226 | 227 | Or use the `bullmq` module directly. You don't need to use Allserver to call remote procedures. See code example below. 228 | 229 | ## Code examples 230 | 231 | ### Procedures 232 | 233 | (aka routes, aka schema, aka handlers, aka functions, aka methods) 234 | 235 | These are your business logic functions. They are exactly the same for all the network protocols out there. They wouldn't need to change if you suddenly need to move them to another (micro)service, protocol, network transport, or expose via a GraphQL API. 236 | 237 | ```js 238 | const procedures = { 239 | async updateUser({ id, firstName, lastName }) { 240 | const db = await require("my-database").db("users"); 241 | 242 | const user = await db.find({ id }); 243 | if (!user) { 244 | return { 245 | success: false, 246 | code: "USER_ID_NOT_FOUND", 247 | message: `User ID ${id} not found`, 248 | }; 249 | } 250 | 251 | if (user.isReadOnly()) { 252 | return { 253 | success: false, 254 | code: "USER_IS_READONLY", 255 | message: `User ${id} can't be modified`, 256 | }; 257 | } 258 | 259 | if (user.firstName === firstName && user.lastName === lastName) { 260 | return { 261 | success: true, // NOTE! We return TRUE here, 262 | code: "NO_CHANGES", // but we also tell the client side that nothing was changed. 263 | message: `User ${id} already have that data`, 264 | }; 265 | } 266 | 267 | user.firstName = firstName; 268 | user.lastName = lastName; 269 | await user.save(); 270 | 271 | return { success: true, code: "UPDATED", user }; 272 | }, 273 | 274 | health() {}, // will return `{"success":true,"code":"SUCCESS","message":"Success"}` 275 | 276 | async reconnectDb() { 277 | const myDb = require("my-database"); 278 | const now = Date.now(); // milliseconds 279 | await myDb.diconnect(); 280 | await myDb.connect(); 281 | const took = Date.now() - now; 282 | return took; // will return `{"reconnectDb":25,"success":true,"code":"SUCCESS","message":"Success"}` 283 | }, 284 | }; 285 | ``` 286 | 287 | ### HTTP server side 288 | 289 | Using the `procedures` declared above. 290 | 291 | ```js 292 | const { Allserver } = require("allserver"); 293 | 294 | Allserver({ procedures }).start(); 295 | ``` 296 | 297 | The above code starts an HTTP server on port `process.env.PORT` if no transport was specified. 298 | 299 | Here is the same server but more explicit: 300 | 301 | ```js 302 | const { Allserver, HttpTransport } = require("allserver"); 303 | 304 | Allserver({ 305 | procedures, 306 | transport: HttpTransport({ port: process.env.PORT }), 307 | }).start(); 308 | ``` 309 | 310 | #### Replying non-standard HTTP response from a procedure 311 | 312 | You'd need to deal with node.js `res` yourself. 313 | 314 | ```js 315 | const procedures = { 316 | processEntity({ someEntity }, ctx) { 317 | const res = ctx.http.res; 318 | res.statusCode = 422; 319 | const msg = "Unprocessable Entity"; 320 | res.setHeader("Content-Length", Buffer.byteLength(msg)); 321 | res.send(msg); 322 | }, 323 | }; 324 | ``` 325 | 326 | #### Accessing Node request and its raw body 327 | 328 | Occasionally, your HTTP method would need to access raw body of a request. This is how you do it: 329 | 330 | ```js 331 | const procedures = { 332 | async processEntity(_, ctx) { 333 | const micro = ctx.allserver.transport.micro; // same as require("micro") 334 | const req = ctx.http.req; // node.js Request 335 | 336 | // as a string 337 | const text = await micro.text(req); 338 | // as a node.js buffer 339 | const buffer = await micro.buffer(req); 340 | 341 | // ... process the request here ... 342 | }, 343 | }; 344 | ``` 345 | 346 | More info can be found in the [`micro`](http://npmjs.com/package/micro) NPM module docs. 347 | 348 | ### HTTP server in Express.js 349 | 350 | Doesn't require a dedicated client transport. Use the HTTP client below. 351 | 352 | ```js 353 | const { Allserver, ExpressTransport } = require("allserver"); 354 | 355 | const middleware = Allserver({ 356 | procedures, 357 | transport: ExpressTransport(), 358 | }).start(); 359 | 360 | app.use("/route-with-allsever", middleware); 361 | ``` 362 | 363 | ### HTTP server in AWS Lambda 364 | 365 | Doesn't require a dedicated client transport. Use the HTTP client below. 366 | 367 | ```js 368 | const { Allserver, LambdaTransport } = require("allserver"); 369 | 370 | exports.handler = Allserver({ 371 | procedures, 372 | transport: LambdaTransport(), 373 | }).start(); 374 | ``` 375 | 376 | ### Server in Bare AWS Lambda 377 | 378 | Invoke directly via AWS SDK or AWS CLI. But better use the LambdaClientTransport (aka `"lambda://"` scheme) below. 379 | 380 | ```js 381 | const { Allserver, LambdaTransport } = require("allserver"); 382 | 383 | exports.handler = Allserver({ 384 | procedures, 385 | transport: LambdaTransport(), 386 | }).start(); 387 | ``` 388 | 389 | ### HTTP client side 390 | 391 | #### Using built-in client 392 | 393 | ```shell 394 | npm i allserver 395 | ``` 396 | 397 | Note, that this code is **same** as the gRPC client code example below! 398 | 399 | ```js 400 | const { AllserverClient } = require("allserver"); 401 | // or 402 | const AllserverClient = require("allserver/Client"); 403 | 404 | const client = AllserverClient({ uri: "http://localhost:40000" }); 405 | 406 | const { success, code, message, user } = await client.updateUser({ 407 | id: "123412341234123412341234", 408 | firstName: "Fred", 409 | lastName: "Flinstone", 410 | }); 411 | ``` 412 | 413 | The `AllserverClient` will issue `HTTP POST` request to this URL: `http://localhost:40000/updateUser`. 414 | The path of the URL is dynamically taken straight from the `client.updateUser` calling code using the ES6 [`Proxy`](https://stackoverflow.com/a/20147219/188475) class. In other words, `AllserverClient` intercepts non-existent property access. 415 | 416 | #### Using any HTTP client (axios in this example) 417 | 418 | It's a regular HTTP `POST` call with JSON request and response. URI is `/updateUser`. 419 | 420 | ```js 421 | import axios from "axios"; 422 | 423 | const response = await axios.post("http://localhost:40000/updateUser", { 424 | id: "123412341234123412341234", 425 | firstName: "Fred", 426 | lastName: "Flinstone", 427 | }); 428 | const { success, code, message, user } = response.data; 429 | ``` 430 | 431 | Alternatively, you can call the same API using `GET` request with search params (query): `http://example.com/updateUser?id=123412341234123412341234&firstName=Fred&lastName=Flinstone` 432 | 433 | ```js 434 | const response = await axios.get("updateUser", { 435 | params: { 436 | id: "123412341234123412341234", 437 | firstName: "Fred", 438 | lastName: "Flinstone", 439 | }, 440 | }); 441 | ``` 442 | 443 | Yeah. This is a mutating call using `HTTP GET`. That's by design, and I love it. Allserver is an RPC server, not a website server! So we are free to do whatever we want here. 444 | 445 | ### HTTP limitations 446 | 447 | 1. Sub-routes are not well-supported, your procedure should be named `"users/updateUser"` or alike. Also, in the `AllserverClient` you'd want to use `nameMapper`. 448 | 449 | ### gRPC server side 450 | 451 | Note that we are reusing the `procedures` from the example above. 452 | 453 | Make sure all the methods in your `.proto` file reply at least three properties: `success, code, message`. Otherwise, the server won't start and will throw an error. 454 | 455 | Also, for now, you need to add [these](./mandatory.proto) mandatory declarations to your `.proto` file. 456 | 457 | Here is how your gRPC server can look like: 458 | 459 | ```js 460 | const { Allserver, GrpcTransport } = require("allserver"); 461 | 462 | Allserver({ 463 | procedures, 464 | transport: GrpcTransport({ 465 | protoFile: __dirname + "/my-server.proto", 466 | port: 50051, 467 | }), 468 | }).start(); 469 | ``` 470 | 471 | ### gRPC client side 472 | 473 | #### Using built-in client 474 | 475 | Note, that this code is **same** as the HTTP client code example above! The only difference is the URI. 476 | 477 | ```js 478 | const { AllserverClient } = require("allserver"); 479 | // or 480 | const AllserverClient = require("allserver/Client"); 481 | 482 | const client = AllserverClient({ uri: "grpc://localhost:50051" }); 483 | 484 | const { success, code, message, user } = await client.updateUser({ 485 | id: "123412341234123412341234", 486 | firstName: "Fred", 487 | lastName: "Flinstone", 488 | }); 489 | ``` 490 | 491 | The `protoFile` is automatically taken from the server side via the `introspect()` call. 492 | 493 | #### Using any gPRS client (official module in this example) 494 | 495 | ```js 496 | const packageDefinition = require("@grpc/proto-loader").loadSync( 497 | __dirname + "/my-server.proto" 498 | ); 499 | 500 | const grpc = require("@grpc/grpc-js"); 501 | const proto = grpc.loadPackageDefinition(packageDefinition); 502 | var client = new proto.MyService( 503 | "localhost:50051", 504 | grpc.credentials.createInsecure() 505 | ); 506 | 507 | // Promisifying because official gRPC modules do not support Promises async/await. 508 | const { promisify } = require("util"); 509 | for (const k in client) 510 | if (typeof client[k] === "function") 511 | client[k] = promisify(client[k].bind(client)); 512 | 513 | const data = await client.updateUser({ 514 | id: "123412341234123412341234", 515 | firstName, 516 | lastName, 517 | }); 518 | 519 | const { success, code, message, user } = data; 520 | ``` 521 | 522 | ### gRPC limitations 523 | 524 | 1. Only [**unary**](https://grpc.io/docs/what-is-grpc/core-concepts/#unary-rpc) RPC. No streaming of any kind is available. By design. 525 | 1. All the reply `message` definitions must have `bool success = 1; string code = 2; string message = 3;`. Otherwise, server won't start. By design. 526 | 1. You can't have `import` statements in your `.proto` file. (Yet.) 527 | 1. Your server-side `.proto` file must include Allserver's [mandatory declarations](./mandatory.proto). (Yet.) 528 | 529 | ### BullMQ server side 530 | 531 | Note that we are reusing the `procedures` from the example above. 532 | 533 | Here is how your BullMQ server can look like: 534 | 535 | ```js 536 | const { Allserver, BullmqTransport } = require("allserver"); 537 | 538 | Allserver({ 539 | procedures, 540 | transport: BullmqTransport({ 541 | connectionOptions: { host: "localhost", port: 6379 }, 542 | }), 543 | }).start(); 544 | ``` 545 | 546 | ### BullMQ client side 547 | 548 | #### Using built-in client 549 | 550 | Note, that this code is **same** as the HTTP client code example above! The only difference is the URI. 551 | 552 | ```js 553 | const { AllserverClient, BullmqClientTransport } = require("allserver"); 554 | // or 555 | const AllserverClient = require("allserver/Client"); 556 | 557 | const client = AllserverClient({ uri: "bullmq://localhost:6379" }); 558 | // or 559 | const client = AllserverClient({ 560 | transport: BullmqClientTransport({ uri: "redis://localhost:6379" }), 561 | }); 562 | 563 | const { success, code, message, user } = await client.updateUser({ 564 | id: "123412341234123412341234", 565 | firstName: "Fred", 566 | lastName: "Flinstone", 567 | }); 568 | ``` 569 | 570 | The `bullmq://` schema uses same connection string as Redis: `bullmq://[[username:]password@]host[:port][/database]` 571 | 572 | #### Using any BullMQ `Queue` class without Allserver 573 | 574 | ```js 575 | const { Queue, QueueEvents } = require("bullmq"); 576 | 577 | const queue = new Queue("Allserver", { 578 | connection: { host: "localhost", port }, 579 | }); 580 | const queueEvents = new QueueEvents("Allserver", { 581 | connection: { host: "localhost", port }, 582 | }); 583 | 584 | const job = await queue.add("updateUser", { 585 | id: "123412341234123412341234", 586 | firstName, 587 | lastName, 588 | }); 589 | const data = await job.waitUntilFinished(queueEvents, 30_000); 590 | 591 | const { success, code, message, user } = data; 592 | ``` 593 | 594 | ### In memory 595 | 596 | Sometimes you need to unit test your procedures via the `AllserverClient`. For that we have `MemoryTransport`. 597 | 598 | ```js 599 | const { Allserver, MemoryTransport } = require("allserver"); 600 | 601 | const memoryServer = Allserver({ 602 | procedures, 603 | transport: MemoryTransport(), 604 | }); 605 | 606 | const client = memoryServer.start(); 607 | 608 | const { success, code, message, user } = await client.updateUser({ 609 | id: "123412341234123412341234", 610 | firstName: "Fred", 611 | lastName: "Flinstone", 612 | }); 613 | 614 | assert(success === true); 615 | ``` 616 | 617 | ### Bare AWS Lambda invocation 618 | 619 | First you need to install the AWS SDK v3. 620 | 621 | ```shell 622 | npm i allserver @aws-sdk/client-lambda 623 | ``` 624 | 625 | The invoke the lambda this way: 626 | 627 | ```js 628 | const { AllserverClient } = require("allserver"); 629 | // or 630 | const AllserverClient = require("allserver/Client"); 631 | 632 | const client = AllserverClient({ uri: "lambda://my-lambda-name" }); 633 | 634 | const { success, code, message, user } = await client.updateUser({ 635 | id: "123412341234123412341234", 636 | firstName: "Fred", 637 | lastName: "Flinstone", 638 | }); 639 | ``` 640 | 641 | #### Using AWS SDK 642 | 643 | As usual, the client side does not require the Allserver packages at all. 644 | 645 | ```js 646 | import { Lambda } from "@aws-sdk/client-lambda"; 647 | 648 | const invocationResponse = await new Lambda().invoke({ 649 | FunctionName: "my-lambda-name", 650 | Payload: JSON.stringify({ 651 | _: { procedureName: "updateUser" }, 652 | id: "123412341234123412341234", 653 | firstName: "Fred", 654 | lastName: "Flinstone", 655 | }), 656 | }); 657 | const { success, code, message, user } = JSON.parse(invocationResponse.Payload); 658 | ``` 659 | 660 | Alternatively, you can call the same procedure using the `aws` CLI: 661 | 662 | ```shell 663 | aws lambda invoke --function-name my-lambda-name --payload '{"_":{"procedureName":"updateUser"},"id":"123412341234123412341234","firstName":"Fred","lastName":"Flinstone"}}' 664 | ``` 665 | 666 | ## `AllserverClient` options 667 | 668 | **All the arguments are optional.** But either `uri` or `transport` must be provided. We are trying to keep the highest possible DX here. 669 | 670 | - `uri`
671 | The remote server address string. Out of box supported schemas are: `http`, `https`, `grpc`, `bullmq`. (More to come.) 672 | 673 | - `transport`
674 | The transport implementation object. The `uri` is ignored if this option provided. If not given then it will be automatically created based on the `uri` schema. E.g. if it starts with `http://` or `https://` then `HttpClientTransport` will be used. If starts with `grpc://` then `GrpcClientTransport` will be used. If starts with `bullmq://` then `BullmqClientTransport` is used. 675 | 676 | - `timeout=60_000`
677 | Set it to `0` if you don't need a timeout. If the procedure call takes longer than this value then the `AllserverClient` will return `success=false` and `code=ALLSERVER_CLIENT_TIMEOUT`. 678 | 679 | - `neverThrow=true`
680 | Set it to `false` if you want to get exceptions when there are a network, or a server errors during a procedure call. Otherwise, the standard `{success,code,message}` object is returned from method calls. The Allserver error `code`s are always start with `"ALLSERVER_"`. E.g. `"ALLSERVER_CLIENT_MALFORMED_INTROSPECTION"`. 681 | 682 | - `dynamicMethods=true`
683 | Automatically find (introspect) and call corresponding remote procedures. If set to `false` the `AllserverClient` would use only the `methods` you defined explicitly client-side. 684 | 685 | - `autoIntrospect=true`
686 | Do not automatically search (introspect) for remote procedures, instead use the runtime method names. This mean `AllserverClient` won't guarantee the procedure existence until you try calling the procedure. E.g., this code `allserverClient.myProcedureName()` will do `POST /myProcedureName` HTTP request (aka "call of faith"). Useful when you don't want to expose introspection HTTP endpoint or don't want to add Allserver's mandatory proto in GRPC server. 687 | 688 | - `callIntrospectedProceduresOnly=true`
689 | If introspection couldn't find a procedure then do not attempt sending a "call of faith" to the server. 690 | 691 | - `nameMapper`
692 | A function to map/filter procedure names found on the server to something else. E.g. `nameMapper: name => _.toCamelCase(name)`. If "falsy" value is returned from `nameMapper()` then this procedure won't be added to the `AllserverClient` object instance, like if it was not found on the server. 693 | 694 | - `before`
695 | The "before" client-side middleware(s). Can be either a function, or an array of functions. 696 | 697 | - `after`
698 | The "after" client-side middleware(s). Can be either a function, or an array of functions. 699 | 700 | ### AllserverClient defaults 701 | 702 | You can change the above mentioned options default values like this: 703 | 704 | ```js 705 | AllseverClient = AllserverClient.defaults({ 706 | transport, 707 | timeout, 708 | neverThrow, 709 | dynamicMethods, 710 | autoIntrospect, 711 | callIntrospectedProceduresOnly, 712 | nameMapper, 713 | before, 714 | after, 715 | }); 716 | 717 | // Then create your client instances as usual: 718 | const httpClient = AllserverClient({ uri: "https://example.com" }); 719 | ``` 720 | 721 | ### Your own client transport 722 | 723 | You can add your own schema support to AllserverClient. 724 | 725 | ```js 726 | AllserverClient = AllserverClient.addTransport({ 727 | schema: "unixsocket", 728 | Transport: MyUnixSocketTransport, 729 | }); 730 | 731 | const client = AllserverClient({ uri: "unixsocket:///example/socket" }); 732 | ``` 733 | 734 | You can overwrite the default client transport implementations: 735 | 736 | ```js 737 | HttpClientTransport = HttpClientTransport.props({ 738 | fetch: require("./fetch-retry"), 739 | }); 740 | AllserverClient = AllserverClient.addTransport({ 741 | schema: "http", 742 | Transport: HttpClientTransport, 743 | }); 744 | AllserverClient = AllserverClient.addTransport({ 745 | schema: "https", 746 | Transport: HttpClientTransport, 747 | }); 748 | ``` 749 | 750 | ## FAQ 751 | 752 | ### What happens if I call a procedure, but the remote server does not reply? 753 | 754 | If using `AllserverClient` you'll get this result, no exceptions thrown client-side by default: 755 | 756 | ```json 757 | { 758 | "success": false, 759 | "code": "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE", 760 | "message": "Couldn't reach remote procedure: procedureName" 761 | } 762 | ``` 763 | 764 | If using your own client module then you'll get your module default behaviour; typically an exception is thrown. 765 | 766 | ### What happens if I call a procedure which does not exist? 767 | 768 | If using `AllserverClient` you'll get this result, no exceptions thrown client-side by default: 769 | 770 | ```json 771 | { 772 | "success": false, 773 | "code": "ALLSERVER_CLIENT_PROCEDURE_NOT_FOUND", 774 | "message": "Procedure 'procedureName' not found" 775 | } 776 | ``` 777 | 778 | If using your own client module then you'll get your module default behaviour; typically an exception is thrown. 779 | 780 | ### What happens if I call a procedure which throws? 781 | 782 | You'll get a normal reply, no exceptions thrown client-side, but the `success` field will be `false`. 783 | 784 | ```json 785 | { 786 | "success": false, 787 | "code": "ALLSERVER_PROCEDURE_ERROR", 788 | "message": "''undefined' is not a function' error in 'procedureName' procedure" 789 | } 790 | ``` 791 | 792 | ### The Allserver logs to console. How to change that? 793 | 794 | In case of internal errors the server would dump the full stack trace to the stderr using its `logger` property (defaults to `console`). Replace the Allserver's logger like this: 795 | 796 | ```js 797 | const allserver = Allserver({ procedures, logger: new MyShinyLogger() }); 798 | // or 799 | Allserver = Allserver.defaults({ logger: new MyShinyLogger() }); 800 | const allserver = Allserver({ procedures }); 801 | ``` 802 | 803 | ### Can I add a server middleware? 804 | 805 | You can add one or multiple pre-middlewares, as well as one or multiple post-middlewares. Anything returned from a middleware (except the `undefined`) becomes the call result, and the rest of the middlewares will be skipped if any. 806 | 807 | The `after` middleware(s) is always called. 808 | 809 | ```js 810 | const allserver = Allserver({ 811 | procedures, 812 | 813 | async before(ctx) { 814 | console.log(ctx.procedureName, ctx.procedure, ctx.arg); 815 | // If you return anything from here, it will become the call result. 816 | }, 817 | async after(ctx) { 818 | console.log(ctx.procedureName, ctx.procedure, ctx.arg); 819 | console.log(ctx.introspection, ctx.result, ctx.error); 820 | // If you return anything from here, it will become the call result. 821 | }, 822 | }); 823 | ``` 824 | 825 | Multiple middlewares example: 826 | 827 | ```js 828 | const allserver = Allserver({ 829 | procedures, 830 | 831 | before: myPreMiddlewaresArray, 832 | after: myPostMiddlewaresArray, 833 | }); 834 | ``` 835 | 836 | ### Can I add a client-side middleware? 837 | 838 | Yep. 839 | 840 | ```js 841 | const { AllserverClient } = require("allserver"); 842 | 843 | const client = AllserverClient({ 844 | uri: "http://example.com:40000", 845 | 846 | async before(ctx) { 847 | console.log(ctx.procedureName, ctx.arg); 848 | // If you return anything from here, it will become the call result. 849 | }, 850 | async after(ctx) { 851 | console.log(ctx.result, ctx.error); 852 | // If you return anything from here, it will become the call result. 853 | }, 854 | }); 855 | ``` 856 | 857 | ### How to add Auth? 858 | 859 | #### Server side 860 | 861 | Server side you do it yourself via the `before` pre-middleware. See above. 862 | 863 | Allserver does not (yet) standardise how the "bad auth" replies should look and feel. That's a discussion we need to take. Refer to the **Core principles** above for insights. 864 | 865 | #### Client side 866 | 867 | This largely depends on the protocol and controlled by the so called "ClientTransport". 868 | 869 | ##### HTTP 870 | 871 | ```js 872 | const { AllserverClient, HttpClientTransport } = require("allserver"); 873 | 874 | const client = AllserverClient({ 875 | transport: HttpClientTransport({ 876 | uri: "http://my-server:40000", 877 | headers: { authorization: "Basic my-token" }, 878 | }), 879 | }); 880 | ``` 881 | 882 | ##### gRPC 883 | 884 | ```js 885 | const { AllserverClient, GrpcClientTransport } = require("allserver"); 886 | 887 | const client = AllserverClient({ 888 | transport: GrpcClientTransport({ 889 | uri: "grpc://my-server:50051", 890 | credentials: require("@grpc/grpc-js").credentials.createSsl(/* ... */), 891 | }), 892 | }); 893 | ``` 894 | 895 | ##### My authorisation is not supported. What should I do? 896 | 897 | If something more sophisticated is needed - you would need to mangle the `ctx` in the client `before` and `after` middlewares. 898 | 899 | ```js 900 | const { AllserverClient } = require("allserver"); 901 | 902 | const client = AllserverClient({ 903 | uri: "http://my-server:40000", 904 | async before(ctx) { 905 | console.log(ctx.procedureName, ctx.arg); 906 | ctx.http.mode = "cors"; 907 | ctx.http.credentials = "include"; 908 | ctx.http.headers.authorization = "Basic my-token"; 909 | }, 910 | async after(ctx) { 911 | if (ctx.error) console.error(ctx.error); 912 | else console.log(ctx.result); 913 | }, 914 | }); 915 | ``` 916 | 917 | Alternatively, you can "inherit" clients: 918 | 919 | ```js 920 | const { AllserverClient } = require("allserver"); 921 | 922 | const MyAllserverClientWithAuth = AllserverClient.defaults({ 923 | async before(ctx) { 924 | ctx.http.mode = "cors"; 925 | ctx.http.credentials = "include"; 926 | ctx.http.headers.authorization = "Basic my-token"; 927 | }, 928 | }); 929 | 930 | const client = MyAllserverClientWithAuth({ 931 | uri: "http://my-server:40000", 932 | }); 933 | ``` 934 | 935 | ### Can I override AllserverClient's method? 936 | 937 | Sure. This is useful if you need to add client-side logic before doing a remote call. 938 | 939 | ```js 940 | const { AllserverClient } = require("allserver"); 941 | const isEmail = require("is-email"); 942 | 943 | const MyRpcClient = AllserverClient.methods({ 944 | async updateContact({ id, email }) { 945 | if (!isEmail(email)) 946 | return { 947 | success: false, 948 | code: "BAD_EMAIL", 949 | message: `${email} is not an email address`, 950 | }; 951 | 952 | // Calling the server 953 | return this.call("updateContact", { id, email }); 954 | }, 955 | }); 956 | 957 | const myRpcClient = MyRpcClient({ uri: anyKindOfSupportedUri }); 958 | const { success } = await myRpcClient.updateContact({ id: 123, email: null }); 959 | console.log(success); // false 960 | ``` 961 | 962 | Important note! **The code above does not require any special handling from you.** The `updateContact()` returns exactly the same interface as the remove server. This is one of the reasons why Allserver exists. 963 | 964 | ### Why `success,code,message`? I want different names. 965 | 966 | - `success` is a generic "all good" / "error happened" reply. Occasionally, you can't determine if it's success or a failure. E.g. if a user tries to unsubscribe from a mailing list, but there is no such user in the list. That's why we have `code`. 967 | - `code` is a hardcoded string for machines. Use it in the source code `if` statements. 968 | - `message` is an arbitrary string for humans. Use it as an error/success message on a UI. 969 | 970 | I considered using `ok` instead of `success`, but Allserver is DX-first. The `ok` can be confused with the `fetch` API `Response.ok` property: `if ((await fetch(uri)).ok)`, we don't want that. Also, the `ok` is not a noun, thus complicates code reading a bit for newbie developers. 971 | 972 | The only mandatory property of the three is `success`. The `code` and `message` are optional. So, you can have different names. Add them to your returned object. Just don't forget to add `success:Boolean` property. 973 | 974 | In the example below we mimic Slack's RPC. They use `ok` and `error` properties similar or Allserver's `success` and `message` properties. 975 | 976 | ```js 977 | const procedures = { 978 | async sendChatMessage({ channel = "#general", message = "" }) { 979 | try { 980 | await sns.sendMessage(/* ... */); 981 | return { success: true, ok: true }; 982 | } catch (err) { 983 | return { success: false, ok: false, error: err.message, status: 50014 }; 984 | } 985 | }, 986 | }; 987 | ``` 988 | 989 | ### TypeScript support? 990 | 991 | We are waiting for your contributions. 992 | -------------------------------------------------------------------------------- /test/client/AllserverClient.test.js: -------------------------------------------------------------------------------- 1 | const assert = require("node:assert/strict"); 2 | const { describe, it, afterEach } = require("node:test"); 3 | 4 | const cls = require("cls-hooked"); 5 | const spaceName = "allserver"; 6 | const session = cls.getNamespace(spaceName) || cls.createNamespace(spaceName); 7 | function getTraceId() { 8 | if (session?.active) { 9 | return session.get("traceId") || ""; 10 | } 11 | 12 | return ""; 13 | } 14 | function setTraceIdAndRunFunction(traceId, func, ...args) { 15 | return new Promise((resolve, reject) => { 16 | session.run(async () => { 17 | session.set("traceId", traceId); 18 | 19 | try { 20 | const result = await func(...args); 21 | resolve(result); 22 | } catch (err) { 23 | reject(err); 24 | } 25 | }); 26 | }); 27 | } 28 | 29 | const VoidClientTransport = require("../..").ClientTransport.compose({ 30 | props: { 31 | uri: "void://localhost", 32 | }, 33 | methods: { 34 | introspect() {}, 35 | call() {}, 36 | createCallContext: (defaultCtx) => ({ ...defaultCtx, void: {} }), 37 | }, 38 | }); 39 | const AllserverClient = require("../..").AllserverClient.addTransport({ 40 | schema: "void", 41 | Transport: VoidClientTransport, 42 | }); 43 | const p = Symbol.for("AllserverClient"); // This would help us accessing protected properties. 44 | 45 | describe("AllserverClient", () => { 46 | afterEach(() => { 47 | // clean introspection cache 48 | AllserverClient.compose.methods._introspectionCache = new Map(); 49 | }); 50 | 51 | describe("#addTransport", () => { 52 | it("should allow overwriting existing configured transports", () => { 53 | const AC = require("../..").AllserverClient; 54 | AC.addTransport({ 55 | schema: "http", 56 | Transport: VoidClientTransport, 57 | }); 58 | }); 59 | }); 60 | 61 | describe("#init", () => { 62 | it("should throw if no uri and no transport", () => { 63 | assert.throws(() => AllserverClient(), /uri/); 64 | }); 65 | 66 | it("should throw if uri schema is not supported", () => { 67 | assert.throws(() => AllserverClient({ uri: "unexist://bla" }), /schema/i); 68 | }); 69 | 70 | it("should work with http", () => { 71 | const client = AllserverClient({ uri: "http://bla/" }); 72 | assert(client[p].transport.fetch); // duck typing 73 | }); 74 | 75 | it("should work with https", () => { 76 | const client = AllserverClient({ uri: "https://bla" }); 77 | assert(client[p].transport.fetch); // duck typing 78 | }); 79 | 80 | it("should work with grpc", () => { 81 | const client = AllserverClient({ uri: "grpc://bla" }); 82 | assert(client[p].transport._grpc); // duck typing 83 | }); 84 | 85 | it("should work with third party added transports supported", () => { 86 | const client = AllserverClient({ uri: "void://bla" }); 87 | assert.equal(client[p].transport.uri, "void://bla"); 88 | }); 89 | 90 | it("should ignore case", () => { 91 | const client = AllserverClient({ uri: "VOID://bla" }); 92 | assert.equal(client[p].transport.uri, "VOID://bla"); 93 | }); 94 | 95 | it("should throw is schema is not supported", () => { 96 | assert.throws( 97 | () => AllserverClient({ uri: "no-schema-here" }), 98 | /`uri` must follow pattern: SCHEMA:\/\/URI/ 99 | ); 100 | }); 101 | 102 | it("should throw is schema is not supported", () => { 103 | assert.throws(() => AllserverClient({ uri: "unexist://bla" }), /Schema not supported: unexist:\/\/bla/); 104 | }); 105 | }); 106 | 107 | describe("#introspect", () => { 108 | it("should not throw if underlying transport fails to connect", async () => { 109 | const MockedTransport = VoidClientTransport.methods({ 110 | introspect: () => Promise.reject(new Error("Cannot reach server")), 111 | }); 112 | 113 | const result = await AllserverClient({ transport: MockedTransport() }).introspect(); 114 | assert.equal(result.success, false); 115 | assert.equal(result.code, "ALLSERVER_CLIENT_INTROSPECTION_FAILED"); 116 | assert.equal(result.message, "Couldn't introspect void://localhost due to: Cannot reach server"); 117 | assert.equal(result.error.message, "Cannot reach server"); 118 | }); 119 | 120 | it("should not throw if underlying transport returns malformed introspection", async () => { 121 | const MockedTransport = VoidClientTransport.methods({ 122 | introspect: () => ({ 123 | success: true, 124 | code: "ALLSERVER_INTROSPECTION", 125 | message: "Introspection as JSON string", 126 | procedures: "bad food", 127 | }), 128 | }); 129 | 130 | const result = await AllserverClient({ transport: MockedTransport() }).testMethod(); 131 | assert.equal(result.success, false); 132 | assert.equal(result.code, "ALLSERVER_CLIENT_MALFORMED_INTROSPECTION"); 133 | assert.equal(result.message, "Malformed introspection from void://localhost"); 134 | assert(result.error.message.includes("Unexpected token")); 135 | }); 136 | 137 | it("should not throw if underlying transport returns introspection in a wrong format", async () => { 138 | const MockedTransport = VoidClientTransport.methods({ 139 | introspect: () => ({ 140 | success: true, 141 | code: "ALLSERVER_INTROSPECTION", 142 | message: "Introspection as JSON string", 143 | procedures: "42", 144 | }), 145 | }); 146 | 147 | const result = await AllserverClient({ transport: MockedTransport() }).testMethod(); 148 | assert.equal(result.success, false); 149 | assert.equal(result.code, "ALLSERVER_CLIENT_MALFORMED_INTROSPECTION"); 150 | assert.equal(result.message, "Malformed introspection from void://localhost"); 151 | assert(!result.error); 152 | }); 153 | }); 154 | 155 | describe("#call", () => { 156 | it("should throw if underlying transport fails to connect and neverThrow=false", async () => { 157 | const MockedTransport = VoidClientTransport.methods({ 158 | call: () => Promise.reject(new Error("Cannot reach server")), 159 | }); 160 | 161 | await assert.rejects( 162 | AllserverClient({ transport: MockedTransport(), neverThrow: false }).call(), 163 | /Cannot reach server/ 164 | ); 165 | }); 166 | 167 | it("should throw if transport 'before' or 'after' middlewares throw and neverThrow=false", async () => { 168 | const transport = VoidClientTransport(); 169 | const before = () => { 170 | throw new Error("before threw"); 171 | }; 172 | await assert.rejects(AllserverClient({ transport, neverThrow: false, before }).call(), /before threw/); 173 | 174 | const after = () => { 175 | throw new Error("after threw"); 176 | }; 177 | 178 | await assert.rejects(AllserverClient({ transport, neverThrow: false, after }).call(), /after threw/); 179 | }); 180 | 181 | it("should not throw if neverThrow enabled (default behaviour)", async () => { 182 | const MockedTransport = VoidClientTransport.methods({ 183 | async introspect() { 184 | return { 185 | success: true, 186 | code: "OK", 187 | message: "Ok", 188 | procedures: JSON.stringify({ foo: "function" }), 189 | }; 190 | }, 191 | call: () => Promise.reject(new Error("Cannot reach server")), 192 | }); 193 | 194 | const result = await AllserverClient({ transport: MockedTransport() }).call("foo", {}); 195 | assert.equal(result.success, false); 196 | assert.equal(result.code, "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE"); 197 | assert.equal(result.message, "Couldn't reach remote procedure foo due to: Cannot reach server"); 198 | assert.equal(result.error.message, "Cannot reach server"); 199 | }); 200 | 201 | it("should not throw if neverThrow enabled and the method is not present", async () => { 202 | const MockedTransport = VoidClientTransport.methods({ 203 | call: () => Promise.reject(new Error("Shit happens too")), 204 | }); 205 | 206 | const client = AllserverClient({ transport: MockedTransport() }); 207 | assert.equal(Reflect.has(client, "foo"), false); // don't have it 208 | const result = await client.call("foo", {}); 209 | assert.equal(Reflect.has(client, "foo"), false); // still don't have it 210 | assert.equal(result.success, false); 211 | assert.equal(result.code, "ALLSERVER_CLIENT_PROCEDURE_UNREACHABLE"); 212 | assert.equal(result.message, "Couldn't reach remote procedure foo due to: Shit happens too"); 213 | assert.equal(result.error.message, "Shit happens too"); 214 | }); 215 | 216 | it("should return ALLSERVER_CLIENT_TIMEOUT code on timeouts", async () => { 217 | const MockedTransport = VoidClientTransport.methods({ 218 | call: () => new Promise(() => {}), // never resolving or rejecting promise 219 | }); 220 | 221 | const client = AllserverClient({ transport: MockedTransport(), timeout: 1 }); // 1 ms timeout for fast unit tests 222 | const result = await client.call("doesnt_matter"); 223 | assert.equal(result.success, false); 224 | assert.equal(result.code, "ALLSERVER_CLIENT_TIMEOUT"); 225 | assert.equal(result.message, "The remote procedure doesnt_matter timed out in 1 ms"); 226 | }); 227 | }); 228 | 229 | describe("dynamicMethods", () => { 230 | it("should not do dynamic RPC based on object property names", () => { 231 | const client = AllserverClient({ dynamicMethods: false, uri: "void://bla" }); 232 | assert.throws(() => client.thisMethodDoesNotExist()); 233 | }); 234 | 235 | it("should do dynamic RPC based on object property names", async () => { 236 | const client = AllserverClient({ autoIntrospect: false, uri: "void://bla" }); 237 | await client.thisMethodDoesNotExist(); 238 | }); 239 | }); 240 | 241 | describe("nameMapper", () => { 242 | it("should map and filter names", async () => { 243 | const MockedTransport = VoidClientTransport.methods({ 244 | async introspect() { 245 | return { 246 | success: true, 247 | code: "OK", 248 | message: "Ok", 249 | procedures: JSON.stringify({ "get-rates": "function", "hide-me": "function" }), 250 | }; 251 | }, 252 | async call({ procedureName, arg }) { 253 | assert.equal(procedureName, "getRates"); 254 | assert.deepEqual(arg, { a: 1, _: { procedureName: "getRates" } }); 255 | return { success: true, code: "CALLED", message: "A is good", b: 42 }; 256 | }, 257 | }); 258 | 259 | const nameMapper = (name) => name !== "hide-me" && name.replace(/(-\w)/g, (k) => k[1].toUpperCase()); 260 | const client = AllserverClient({ transport: MockedTransport(), nameMapper }); 261 | assert.equal(Reflect.has(client, "getRates"), false); // dont have it yet 262 | const result = await client.getRates({ a: 1 }); 263 | assert.equal(Reflect.has(client, "getRates"), true); // have it now! 264 | assert.deepEqual(result, { success: true, code: "CALLED", message: "A is good", b: 42 }); 265 | }); 266 | }); 267 | 268 | describe("client middleware", () => { 269 | describe("'before'", () => { 270 | it("should call 'before'", async () => { 271 | let introspected = false; 272 | const MockedTransport = VoidClientTransport.methods({ 273 | async introspect(ctx) { 274 | assert(ctx.isIntrospection); 275 | introspected = true; 276 | return { 277 | success: true, 278 | code: "OK", 279 | message: "Ok", 280 | procedures: JSON.stringify({ getRates: "function" }), 281 | }; 282 | }, 283 | async call({ procedureName, arg }) { 284 | assert.equal(procedureName, "getRates"); 285 | assert.deepEqual(arg, { a: 1, _: { procedureName: "getRates" } }); 286 | return { success: true, code: "CALLED", message: "A is good", b: 42 }; 287 | }, 288 | }); 289 | 290 | let beforeCalled = 0; 291 | function before(ctx) { 292 | assert.equal(this, client, "The `this` context must be the client itself"); 293 | if (ctx.isIntrospection) { 294 | assert(!ctx.procedureName); 295 | } else { 296 | assert.equal(ctx.procedureName, "getRates"); 297 | assert.deepEqual(ctx.arg, { a: 1, _: { procedureName: "getRates" } }); 298 | } 299 | beforeCalled += 1; 300 | } 301 | 302 | const client = AllserverClient({ transport: MockedTransport(), before }); 303 | const result = await client.getRates({ a: 1 }); 304 | assert.deepEqual(result, { success: true, code: "CALLED", message: "A is good", b: 42 }); 305 | assert.equal(beforeCalled, 2); 306 | assert(introspected); 307 | }); 308 | 309 | it("should allow result override in 'before'", async () => { 310 | let defaultMiddlewareCalled = false; 311 | const DefaultedAllserverClient = AllserverClient.defaults({ 312 | callIntrospectedProceduresOnly: false, 313 | before() { 314 | defaultMiddlewareCalled = true; 315 | }, 316 | }); 317 | const before = () => { 318 | return "Override result"; 319 | }; 320 | const client = DefaultedAllserverClient({ transport: VoidClientTransport(), before }); 321 | assert.equal(client[p].before.length, 2, "Default middleware should be present"); 322 | const result = await client.foo(); 323 | assert.deepEqual(result, "Override result"); 324 | assert.ok(defaultMiddlewareCalled); 325 | }); 326 | 327 | it("should handle rejections from 'before'", async () => { 328 | const err = new Error("'before' is throwing"); 329 | const before = () => { 330 | throw err; 331 | }; 332 | const client = AllserverClient({ 333 | transport: VoidClientTransport(), 334 | before, 335 | callIntrospectedProceduresOnly: false, 336 | }); 337 | const result = await client.foo(); 338 | assert.deepEqual(result, { 339 | success: false, 340 | code: "ALLSERVER_CLIENT_MIDDLEWARE_ERROR", 341 | message: "The 'before' middleware error while calling 'foo' procedure: 'before' is throwing", 342 | error: err, 343 | }); 344 | }); 345 | 346 | it("should override code if 'before' error has it", async () => { 347 | const err = new Error("'before' is throwing"); 348 | err.code = "OVERRIDE_CODE"; 349 | const before = () => { 350 | throw err; 351 | }; 352 | const client = AllserverClient({ 353 | transport: VoidClientTransport(), 354 | before, 355 | callIntrospectedProceduresOnly: false, 356 | }); 357 | const result = await client.foo(); 358 | assert.deepEqual(result, { 359 | success: false, 360 | code: "OVERRIDE_CODE", 361 | message: "'before' is throwing", 362 | error: err, 363 | }); 364 | }); 365 | 366 | it("should preserve async_hooks context in 'before'", async () => { 367 | let called = []; 368 | const client = AllserverClient({ 369 | transport: VoidClientTransport(), 370 | autoIntrospect: false, 371 | before: [ 372 | (ctx, next) => { 373 | setTraceIdAndRunFunction("my-random-trace-id", next); 374 | }, 375 | () => { 376 | assert.equal(getTraceId(), "my-random-trace-id"); 377 | called.push(1); 378 | return undefined; 379 | }, 380 | () => { 381 | assert.equal(getTraceId(), "my-random-trace-id"); 382 | called.push(2); 383 | return { success: false, code: "BAD_AUTH_OR_SOMETHING", message: "Bad auth or something" }; 384 | }, 385 | () => { 386 | called.push(3); 387 | assert.fail("should not be called"); 388 | }, 389 | ], 390 | }); 391 | 392 | const result = await client.foo(); 393 | 394 | assert.deepEqual(result, { 395 | success: false, 396 | code: "BAD_AUTH_OR_SOMETHING", 397 | message: "Bad auth or something", 398 | }); 399 | assert.deepEqual(called, [1, 2]); 400 | }); 401 | }); 402 | 403 | describe("'after'", () => { 404 | it("should call 'after'", async () => { 405 | let introspected = false; 406 | const MockedTransport = VoidClientTransport.methods({ 407 | async introspect(ctx) { 408 | assert(ctx.isIntrospection); 409 | introspected = true; 410 | return { 411 | success: true, 412 | code: "OK", 413 | message: "Ok", 414 | procedures: JSON.stringify({ getRates: "function" }), 415 | }; 416 | }, 417 | async call({ procedureName, arg }) { 418 | assert.equal(procedureName, "getRates"); 419 | assert.deepEqual(arg, { a: 1, _: { procedureName: "getRates" } }); 420 | return { success: true, code: "CALLED", message: "A is good", b: 42 }; 421 | }, 422 | }); 423 | 424 | let afterCalled = 0; 425 | function after(ctx) { 426 | assert.equal(this, client, "The `this` context must be the client itself"); 427 | if (ctx.isIntrospection) { 428 | assert(!ctx.procedureName); 429 | } else { 430 | assert.equal(ctx.procedureName, "getRates"); 431 | assert.deepEqual(ctx.arg, { a: 1, _: { procedureName: "getRates" } }); 432 | assert.deepEqual(ctx.result, { 433 | success: true, 434 | code: "CALLED", 435 | message: "A is good", 436 | b: 42, 437 | }); 438 | } 439 | afterCalled += 1; 440 | } 441 | const client = AllserverClient({ transport: MockedTransport(), after }); 442 | const result = await client.getRates({ a: 1 }); 443 | assert.deepEqual(result, { success: true, code: "CALLED", message: "A is good", b: 42 }); 444 | assert.equal(afterCalled, 2); 445 | assert(introspected); 446 | }); 447 | 448 | it("should allow result override in 'after'", async () => { 449 | let defaultMiddlewareCalled = false; 450 | const DefaultedAllserverClient = AllserverClient.defaults({ 451 | after() { 452 | defaultMiddlewareCalled = true; 453 | }, 454 | callIntrospectedProceduresOnly: false, 455 | }); 456 | const after = () => { 457 | return "Override result"; 458 | }; 459 | const client = DefaultedAllserverClient({ transport: VoidClientTransport(), after }); 460 | assert.equal(client[p].after.length, 2, "Default middleware should be present"); 461 | const result = await client.foo(); 462 | assert.deepEqual(result, "Override result"); 463 | assert.ok(defaultMiddlewareCalled); 464 | }); 465 | 466 | it("should handle rejections from 'after'", async () => { 467 | const err = new Error("'after' is throwing"); 468 | const after = () => { 469 | throw err; 470 | }; 471 | const client = AllserverClient({ 472 | transport: VoidClientTransport(), 473 | after, 474 | callIntrospectedProceduresOnly: false, 475 | }); 476 | const result = await client.foo(); 477 | assert.deepEqual(result, { 478 | success: false, 479 | code: "ALLSERVER_CLIENT_MIDDLEWARE_ERROR", 480 | message: "The 'after' middleware error while calling 'foo' procedure: 'after' is throwing", 481 | error: err, 482 | }); 483 | }); 484 | 485 | it("should override code if 'after' error has it", async () => { 486 | const err = new Error("'after' is throwing"); 487 | err.code = "OVERRIDE_CODE"; 488 | const after = () => { 489 | throw err; 490 | }; 491 | const client = AllserverClient({ 492 | transport: VoidClientTransport(), 493 | after, 494 | callIntrospectedProceduresOnly: false, 495 | }); 496 | const result = await client.foo(); 497 | assert.deepEqual(result, { 498 | success: false, 499 | code: "OVERRIDE_CODE", 500 | message: "'after' is throwing", 501 | error: err, 502 | }); 503 | }); 504 | 505 | it("should preserve async_hooks context in 'after'", async () => { 506 | let called = []; 507 | const client = AllserverClient({ 508 | transport: VoidClientTransport(), 509 | autoIntrospect: false, 510 | before: [ 511 | (ctx, next) => { 512 | setTraceIdAndRunFunction("my-random-trace-id", next); 513 | }, 514 | ], 515 | after: [ 516 | () => { 517 | assert.equal(getTraceId(), "my-random-trace-id"); 518 | called.push(1); 519 | }, 520 | () => { 521 | assert.equal(getTraceId(), "my-random-trace-id"); 522 | called.push(2); 523 | }, 524 | ], 525 | }); 526 | 527 | await client.foo(); 528 | 529 | assert.deepEqual(called, [1, 2]); 530 | }); 531 | }); 532 | 533 | describe("'before'+'after'", () => { 534 | it("should call 'after' even if 'before' throws", async () => { 535 | let afterCalled = false; 536 | let secondBeforeCalled = false; 537 | let secondAfterCalled = false; 538 | const err = new Error("'before' is throwing"); 539 | const client = AllserverClient({ 540 | callIntrospectedProceduresOnly: false, 541 | transport: VoidClientTransport(), 542 | before: [ 543 | () => { 544 | throw err; 545 | }, 546 | () => { 547 | secondBeforeCalled = true; 548 | }, 549 | ], 550 | after: [ 551 | (ctx) => { 552 | afterCalled = true; 553 | return ctx.result; 554 | }, 555 | () => { 556 | secondAfterCalled = true; 557 | }, 558 | ], 559 | }); 560 | const result = await client.foo(); 561 | assert(afterCalled); 562 | assert(!secondBeforeCalled); 563 | assert(!secondAfterCalled); 564 | assert.deepEqual(result, { 565 | success: false, 566 | code: "ALLSERVER_CLIENT_MIDDLEWARE_ERROR", 567 | message: "The 'before' middleware error while calling 'foo' procedure: 'before' is throwing", 568 | error: err, 569 | }); 570 | }); 571 | }); 572 | }); 573 | 574 | describe("autoIntrospect", () => { 575 | it("should introspect and add methods before call", async () => { 576 | const MockedTransport = VoidClientTransport.methods({ 577 | async introspect() { 578 | return { 579 | success: true, 580 | code: "OK", 581 | message: "Ok", 582 | procedures: JSON.stringify({ foo: "function" }), 583 | }; 584 | }, 585 | async call({ procedureName, arg }) { 586 | assert.equal(procedureName, "foo"); 587 | assert.deepEqual(arg, { a: 1, _: { procedureName: "foo" } }); 588 | return { success: true, code: "CALLED_A", message: "A is good", b: 42 }; 589 | }, 590 | }); 591 | 592 | const client = AllserverClient({ transport: MockedTransport() }); 593 | assert.equal(Reflect.has(client, "foo"), false); // dont have it yet 594 | const result = await client.foo({ a: 1 }); 595 | assert.equal(Reflect.has(client, "foo"), true); // have it now! 596 | assert.deepEqual(result, { success: true, code: "CALLED_A", message: "A is good", b: 42 }); 597 | }); 598 | 599 | it("should attempt calling if introspection fails", async () => { 600 | const MockedTransport = VoidClientTransport.methods({ 601 | introspect() { 602 | return Promise.reject(new Error("Couldn't introspect")); 603 | }, 604 | call({ procedureName, arg }) { 605 | assert.equal(procedureName, "foo"); 606 | assert.deepEqual(arg, { a: 1, _: { procedureName: "foo" } }); 607 | return { success: true, code: "CALLED_A", message: "A is good", b: 42 }; 608 | }, 609 | }); 610 | 611 | const client = AllserverClient({ transport: MockedTransport(), callIntrospectedProceduresOnly: false }); 612 | assert.equal(Reflect.has(client, "foo"), false); // don't have it 613 | const result = await AllserverClient({ 614 | transport: MockedTransport(), 615 | callIntrospectedProceduresOnly: false, 616 | }).foo({ a: 1 }); 617 | assert.equal(Reflect.has(client, "foo"), false); // still don't have it 618 | assert.deepEqual(result, { success: true, code: "CALLED_A", message: "A is good", b: 42 }); 619 | }); 620 | 621 | it("should not override existing methods", async () => { 622 | const MockedTransport = VoidClientTransport.methods({ 623 | async introspect() { 624 | return { 625 | success: true, 626 | code: "OK", 627 | message: "Ok", 628 | procedures: JSON.stringify({ foo: "function" }), 629 | }; 630 | }, 631 | async call() { 632 | assert.fail("should not call transport"); 633 | }, 634 | }); 635 | 636 | function foo(arg) { 637 | assert.equal(arg && arg.a, 1); 638 | return { 639 | success: true, 640 | code: "CACHED", 641 | message: "The reply mimics memory caching", 642 | b: 2, 643 | }; 644 | } 645 | 646 | const client = AllserverClient.methods({ foo }).create({ transport: MockedTransport() }); 647 | assert.equal(client.foo, foo); 648 | const result = await client.foo({ a: 1 }); 649 | assert.equal(client.foo, foo); 650 | assert.deepEqual(result, { 651 | success: true, 652 | code: "CACHED", 653 | message: "The reply mimics memory caching", 654 | b: 2, 655 | }); 656 | }); 657 | 658 | it("should introspect same uri only once", async () => { 659 | let introspectionCalls = 0; 660 | const MockedTransport = VoidClientTransport.methods({ 661 | async introspect() { 662 | introspectionCalls += 1; 663 | return { 664 | success: true, 665 | code: "OK", 666 | message: "Ok", 667 | procedures: JSON.stringify({ foo: "function" }), 668 | }; 669 | }, 670 | async call() { 671 | return { success: true, code: "CALLED_A", message: "A is good", b: 42 }; 672 | }, 673 | }); 674 | 675 | for (let i = 1; i <= 2; i += 1) { 676 | const client = AllserverClient({ transport: MockedTransport({ uri: "void://very-unique-address-1" }) }); 677 | assert.equal(Reflect.has(client, "foo"), false); // dont have it yet 678 | const result = await client.foo({ a: 1 }); 679 | assert.equal(Reflect.has(client, "foo"), true); // have it now! 680 | assert.deepEqual(result, { success: true, code: "CALLED_A", message: "A is good", b: 42 }); 681 | } 682 | 683 | assert.equal(introspectionCalls, 1); 684 | }); 685 | 686 | it("should re-introspect failed introspections", async () => { 687 | let introspectionCalls = 0; 688 | const MockedTransport = VoidClientTransport.methods({ 689 | async introspect() { 690 | introspectionCalls += 1; 691 | throw new Error("Shit happens twice"); 692 | }, 693 | async call() { 694 | return { success: true, code: "CALLED_A", message: "A is good", b: 42 }; 695 | }, 696 | }); 697 | 698 | for (let i = 1; i <= 2; i += 1) { 699 | const client = AllserverClient({ 700 | transport: MockedTransport({ uri: "void://very-unique-address-2" }), 701 | callIntrospectedProceduresOnly: false, 702 | }); 703 | assert.equal(Reflect.has(client, "foo"), false); // dont have it 704 | const result = await client.foo({ a: 1 }); 705 | assert.equal(Reflect.has(client, "foo"), false); // still don't have it 706 | assert.deepEqual(result, { success: true, code: "CALLED_A", message: "A is good", b: 42 }); 707 | } 708 | 709 | assert.equal(introspectionCalls, 2); 710 | }); 711 | 712 | it("should not auto introspect if asked so", (done) => { 713 | const MockedTransport = VoidClientTransport.methods({ 714 | async introspect() { 715 | done(new Error("Must not attempt introspection")); 716 | }, 717 | async call() { 718 | return { success: true, code: "CALLED_A", message: "A is good", b: 42 }; 719 | }, 720 | }); 721 | 722 | const client = AllserverClient({ 723 | autoIntrospect: false, 724 | transport: MockedTransport({ uri: "void://very-unique-address-3" }), 725 | }); 726 | assert.equal(Reflect.has(client, "foo"), false); // dont have it 727 | client 728 | .foo({ a: 1 }) 729 | .then((result) => { 730 | assert.equal(Reflect.has(client, "foo"), false); // still don't have it 731 | assert.deepEqual(result, { success: true, code: "CALLED_A", message: "A is good", b: 42 }); 732 | done(); 733 | }) 734 | .catch(done); 735 | }); 736 | 737 | it("should return original error if introspection fails", (done) => { 738 | const error = new Error("My error"); 739 | const MockedTransport = VoidClientTransport.methods({ 740 | async introspect() { 741 | return { success: false, code: "SOME_ERROR", message: "My message", error }; 742 | }, 743 | async call() { 744 | done(new Error("Must not attempt calling procedures")); 745 | }, 746 | }); 747 | 748 | const client = AllserverClient({ 749 | transport: MockedTransport({ uri: "void://very-unique-address-3" }), 750 | }); 751 | assert.equal(Reflect.has(client, "foo"), false); // dont have it 752 | client 753 | .foo({ a: 1 }) 754 | .then((result) => { 755 | assert.equal(Reflect.has(client, "foo"), false); // still don't have it 756 | assert.deepEqual(result, { 757 | success: false, 758 | code: "SOME_ERROR", 759 | message: "My message", 760 | error, 761 | }); 762 | done(); 763 | }) 764 | .catch(done); 765 | }); 766 | }); 767 | 768 | describe("#defaults", () => { 769 | it("should work", () => { 770 | const NewClient = AllserverClient.defaults({ 771 | neverThrow: false, 772 | dynamicMethods: false, 773 | autoIntrospect: false, 774 | nameMapper: (a) => a, 775 | }); 776 | 777 | function protectedsAreOk(protecteds) { 778 | assert.equal(protecteds.neverThrow, false); 779 | assert.equal(protecteds.dynamicMethods, false); 780 | assert.equal(protecteds.autoIntrospect, false); 781 | assert.equal(typeof protecteds.nameMapper, "function"); 782 | } 783 | 784 | protectedsAreOk(NewClient.compose.deepProperties[p]); 785 | protectedsAreOk(NewClient({ uri: "void://bla" })[p]); 786 | }); 787 | 788 | it("should merge middlewares if supplied in the constructor too", () => { 789 | const before = () => {}; 790 | const before2 = () => {}; 791 | const after = () => {}; 792 | const after2 = () => {}; 793 | const NewClient = AllserverClient.defaults({ 794 | neverThrow: false, 795 | dynamicMethods: false, 796 | autoIntrospect: false, 797 | nameMapper: (a) => a, 798 | before, 799 | after, 800 | }); 801 | 802 | function protectedsAreOk(protecteds) { 803 | assert.equal(protecteds.neverThrow, false); 804 | assert.equal(protecteds.dynamicMethods, false); 805 | assert.equal(protecteds.autoIntrospect, false); 806 | assert.equal(typeof protecteds.nameMapper, "function"); 807 | assert.deepEqual(protecteds.before, [before, before2]); 808 | assert.deepEqual(protecteds.after, [after, after2]); 809 | } 810 | 811 | protectedsAreOk(NewClient({ uri: "void://bla", before: before2, after: after2 })[p]); 812 | }); 813 | 814 | it("should create new factory", () => { 815 | assert.notEqual(AllserverClient, AllserverClient.defaults()); 816 | }); 817 | }); 818 | }); 819 | --------------------------------------------------------------------------------