From 037ca36026754e081a030c7b44f21b628e687257 Mon Sep 17 00:00:00 2001 From: Peter van Vliet Date: Wed, 28 Aug 2024 14:16:02 +0200 Subject: [PATCH] #296: split off the services, health and middleware --- package-lock.json | 49 +++++- packages/execution/README.md | 2 +- packages/health/CHANGELOG.md | 4 + packages/health/README.md | 9 + packages/health/package.json | 44 +++++ packages/health/src/HealthManager.ts | 98 +++++++++++ .../health/src/errors/InvalidHealthCheck.ts | 19 +++ packages/health/src/index.ts | 4 + packages/health/src/interfaces/HealthCheck.ts | 11 ++ packages/health/tsconfig.json | 21 +++ packages/health/vite.config.ts | 10 ++ packages/middleware/CHANGELOG.md | 4 + packages/middleware/README.md | 9 + packages/middleware/package.json | 45 +++++ packages/middleware/src/MiddlewareManager.ts | 72 ++++++++ .../src/errors/InvalidMiddleware.ts | 19 +++ .../src/implementations/ProcedureRunner.ts | 21 +++ packages/middleware/src/index.ts | 8 + .../middleware/src/interfaces/Middleware.ts | 11 ++ packages/middleware/src/types/NextHandler.ts | 6 + packages/middleware/tsconfig.json | 21 +++ packages/middleware/vite.config.ts | 10 ++ packages/services/CHANGELOG.md | 4 + packages/services/README.md | 9 + packages/services/package.json | 47 ++++++ packages/services/src/Client.ts | 55 ++++++ packages/services/src/Remote.ts | 157 ++++++++++++++++++ packages/services/src/RunnerService.ts | 11 ++ packages/services/src/Service.ts | 13 ++ packages/services/src/gateway/Gateway.ts | 8 + packages/services/src/gateway/LocalGateway.ts | 96 +++++++++++ .../services/src/gateway/RemoteGateway.ts | 69 ++++++++ .../services/src/gateway/WorkerBalancer.ts | 61 +++++++ .../services/src/gateway/WorkerManager.ts | 91 ++++++++++ .../services/src/gateway/WorkerMonitor.ts | 64 +++++++ .../src/gateway/errors/InvalidTrustKey.ts | 13 ++ .../src/gateway/errors/NoWorkerAvailable.ts | 19 +++ packages/services/src/index.ts | 20 +++ packages/services/src/proxy/Proxy.ts | 80 +++++++++ .../src/repository/LocalRepository.ts | 57 +++++++ .../src/repository/RemoteRepository.ts | 51 ++++++ .../services/src/repository/Repository.ts | 9 + packages/services/src/worker/LocalWorker.ts | 133 +++++++++++++++ packages/services/src/worker/RemoteWorker.ts | 66 ++++++++ packages/services/src/worker/Worker.ts | 7 + .../src/worker/errors/InvalidTrustKey.ts | 13 ++ packages/services/tsconfig.json | 21 +++ packages/services/vite.config.ts | 10 ++ 48 files changed, 1677 insertions(+), 4 deletions(-) create mode 100644 packages/health/CHANGELOG.md create mode 100644 packages/health/README.md create mode 100644 packages/health/package.json create mode 100644 packages/health/src/HealthManager.ts create mode 100644 packages/health/src/errors/InvalidHealthCheck.ts create mode 100644 packages/health/src/index.ts create mode 100644 packages/health/src/interfaces/HealthCheck.ts create mode 100644 packages/health/tsconfig.json create mode 100644 packages/health/vite.config.ts create mode 100644 packages/middleware/CHANGELOG.md create mode 100644 packages/middleware/README.md create mode 100644 packages/middleware/package.json create mode 100644 packages/middleware/src/MiddlewareManager.ts create mode 100644 packages/middleware/src/errors/InvalidMiddleware.ts create mode 100644 packages/middleware/src/implementations/ProcedureRunner.ts create mode 100644 packages/middleware/src/index.ts create mode 100644 packages/middleware/src/interfaces/Middleware.ts create mode 100644 packages/middleware/src/types/NextHandler.ts create mode 100644 packages/middleware/tsconfig.json create mode 100644 packages/middleware/vite.config.ts create mode 100644 packages/services/CHANGELOG.md create mode 100644 packages/services/README.md create mode 100644 packages/services/package.json create mode 100644 packages/services/src/Client.ts create mode 100644 packages/services/src/Remote.ts create mode 100644 packages/services/src/RunnerService.ts create mode 100644 packages/services/src/Service.ts create mode 100644 packages/services/src/gateway/Gateway.ts create mode 100644 packages/services/src/gateway/LocalGateway.ts create mode 100644 packages/services/src/gateway/RemoteGateway.ts create mode 100644 packages/services/src/gateway/WorkerBalancer.ts create mode 100644 packages/services/src/gateway/WorkerManager.ts create mode 100644 packages/services/src/gateway/WorkerMonitor.ts create mode 100644 packages/services/src/gateway/errors/InvalidTrustKey.ts create mode 100644 packages/services/src/gateway/errors/NoWorkerAvailable.ts create mode 100644 packages/services/src/index.ts create mode 100644 packages/services/src/proxy/Proxy.ts create mode 100644 packages/services/src/repository/LocalRepository.ts create mode 100644 packages/services/src/repository/RemoteRepository.ts create mode 100644 packages/services/src/repository/Repository.ts create mode 100644 packages/services/src/worker/LocalWorker.ts create mode 100644 packages/services/src/worker/RemoteWorker.ts create mode 100644 packages/services/src/worker/Worker.ts create mode 100644 packages/services/src/worker/errors/InvalidTrustKey.ts create mode 100644 packages/services/tsconfig.json create mode 100644 packages/services/vite.config.ts diff --git a/package-lock.json b/package-lock.json index 47232b03..b7d56ee8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1259,6 +1259,14 @@ "resolved": "packages/execution", "link": true }, + "node_modules/@jitar/health": { + "resolved": "packages/health", + "link": true + }, + "node_modules/@jitar/middleware": { + "resolved": "packages/middleware", + "link": true + }, "node_modules/@jitar/plugin-vite": { "resolved": "packages/plugin-vite", "link": true @@ -1279,6 +1287,10 @@ "resolved": "packages/server-nodejs", "link": true }, + "node_modules/@jitar/services": { + "resolved": "packages/services", + "link": true + }, "node_modules/@jitar/sourcing": { "resolved": "packages/sourcing", "link": true @@ -13922,8 +13934,8 @@ "license": "MIT", "dependencies": { "@jitar/configuration": "*", + "@jitar/execution": "*", "@jitar/reflection": "*", - "@jitar/runtime": "*", "@jitar/sourcing": "*" } }, @@ -13933,8 +13945,7 @@ "license": "MIT", "dependencies": { "@jitar/caching": "*", - "@jitar/configuration": "*", - "@jitar/sourcing": "*" + "@jitar/configuration": "*" } }, "packages/configuration": { @@ -13969,6 +13980,16 @@ } }, "packages/execution": { + "name": "@jitar/execution", + "version": "0.7.4", + "license": "MIT", + "dependencies": { + "@jitar/errors": "*", + "@jitar/sourcing": "*" + } + }, + "packages/health": { + "name": "@jitar/health", "version": "0.7.4", "license": "MIT", "dependencies": { @@ -14051,6 +14072,16 @@ "node": ">=10" } }, + "packages/middleware": { + "name": "@jitar/middleware", + "version": "0.7.4", + "license": "MIT", + "dependencies": { + "@jitar/errors": "*", + "@jitar/execution": "*", + "@jitar/sourcing": "*" + } + }, "packages/plugin-vite": { "name": "@jitar/plugin-vite", "version": "0.7.4", @@ -14157,6 +14188,18 @@ "node": ">=10" } }, + "packages/services": { + "name": "@jitar/services", + "version": "0.7.4", + "license": "MIT", + "dependencies": { + "@jitar/errors": "*", + "@jitar/execution": "*", + "@jitar/health": "*", + "@jitar/middleware": "*", + "@jitar/sourcing": "*" + } + }, "packages/sourcing": { "name": "@jitar/sourcing", "version": "0.7.4", diff --git a/packages/execution/README.md b/packages/execution/README.md index f1b1418f..c6af179f 100644 --- a/packages/execution/README.md +++ b/packages/execution/README.md @@ -1,5 +1,5 @@ -# Jitar Configuration +# Jitar Execution This package contains the components for the execution of procedures from [Jitar](https://jitar.dev) applications. diff --git a/packages/health/CHANGELOG.md b/packages/health/CHANGELOG.md new file mode 100644 index 00000000..80f29432 --- /dev/null +++ b/packages/health/CHANGELOG.md @@ -0,0 +1,4 @@ + +# Changelog + +This package doesn't keep a changelog. See the changelog in the [github repository](https://github.com/MaskingTechnology/jitar/blob/main/CHANGELOG.md) \ No newline at end of file diff --git a/packages/health/README.md b/packages/health/README.md new file mode 100644 index 00000000..8bbbeda9 --- /dev/null +++ b/packages/health/README.md @@ -0,0 +1,9 @@ + +# Jitar Health + +This package contains the components for health monitoring of the [Jitar](https://jitar.dev) runtime. + +For more information about Jitar: + +* [Visit our website](https://jitar.dev) +* [Read the documentation](https://docs.jitar.dev). diff --git a/packages/health/package.json b/packages/health/package.json new file mode 100644 index 00000000..51744be1 --- /dev/null +++ b/packages/health/package.json @@ -0,0 +1,44 @@ +{ + "name": "@jitar/health", + "version": "0.7.4", + "description": "Health library for the Jitar runtime.", + "author": "Masking Technology (https://jitar.dev)", + "license": "MIT", + "type": "module", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "CHANGELOG.md", + "README.md", + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "vitest run", + "test-coverage": "vitest run --coverage", + "lint": "eslint . --ext .ts", + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "@jitar/errors": "*", + "@jitar/sourcing": "*" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/MaskingTechnology/jitar.git" + }, + "bugs": { + "url": "https://github.com/MaskingTechnology/jitar/issues" + }, + "homepage": "https://jitar.dev", + "keywords": [ + "configuration", + "jitar" + ] +} diff --git a/packages/health/src/HealthManager.ts b/packages/health/src/HealthManager.ts new file mode 100644 index 00000000..f6f59568 --- /dev/null +++ b/packages/health/src/HealthManager.ts @@ -0,0 +1,98 @@ + +import { SourcingManager } from '@jitar/sourcing'; + +import InvalidHealthCheck from './errors/InvalidHealthCheck'; +import HealthCheck from './interfaces/HealthCheck'; + +export default class HealthManager +{ + #sourcingManager: SourcingManager; + #healthChecks: Map = new Map(); + + constructor(sourcingManager: SourcingManager) + { + this.#sourcingManager = sourcingManager; + } + + async importHealthCheck(filename: string): Promise + { + const module = await this.#sourcingManager.import(filename); + const healthCheck = module.default as HealthCheck; + + if (healthCheck?.isHealthy === undefined) + { + throw new InvalidHealthCheck(filename); + } + + this.addHealthCheck(healthCheck as HealthCheck); + } + + addHealthCheck(healthCheck: HealthCheck): void + { + this.#healthChecks.set(healthCheck.name, healthCheck); + } + + clearHealthChecks(): void + { + this.#healthChecks.clear(); + } + + async isHealthy(): Promise + { + const promises: Promise[] = []; + + for (const healthCheck of this.#healthChecks.values()) + { + const promise = this.#executeHealthCheck(healthCheck); + + promises.push(promise); + } + + return Promise.all(promises) + .then(results => results.every(result => result)) + .catch(() => false); + } + + async getHealth(): Promise> + { + const promises: Promise<{ name: string, isHealthy: boolean }>[] = []; + + for (const [name, healthCheck] of this.#healthChecks) + { + const promise = this.#executeHealthCheck(healthCheck) + .then(result => ({ name, isHealthy: result })) + .catch(() => ({ name, isHealthy: false })); + + promises.push(promise); + } + + const healthChecks = new Map(); + + return Promise.allSettled(promises) + .then(results => results.forEach(result => + { + result.status === 'fulfilled' + ? healthChecks.set(result.value.name, result.value.isHealthy) + : healthChecks.set(result.reason.name, false); + })) + .then(() => healthChecks); + } + + async #executeHealthCheck(healthCheck: HealthCheck): Promise + { + const health = healthCheck.isHealthy(); + const milliseconds = healthCheck.timeout; + + if (milliseconds === undefined) + { + return health; + } + + const timeout = new Promise((resolve) => + { + setTimeout(resolve, milliseconds); + }).then(() => false); + + return Promise.race([timeout, health]); + } +} diff --git a/packages/health/src/errors/InvalidHealthCheck.ts b/packages/health/src/errors/InvalidHealthCheck.ts new file mode 100644 index 00000000..10d90fe3 --- /dev/null +++ b/packages/health/src/errors/InvalidHealthCheck.ts @@ -0,0 +1,19 @@ + +import { ServerError } from '@jitar/errors'; +import { Loadable } from '@jitar/serialization'; + +export default class InvalidHealthCheck extends ServerError +{ + #url: string; + + constructor(url: string) + { + super(`Module '${url}' does not export a valid health check`); + + this.#url = url; + } + + get url() { return this.#url; } +} + +(InvalidHealthCheck as Loadable).source = 'RUNTIME_ERROR_LOCATION'; diff --git a/packages/health/src/index.ts b/packages/health/src/index.ts new file mode 100644 index 00000000..c2c87bcf --- /dev/null +++ b/packages/health/src/index.ts @@ -0,0 +1,4 @@ + +export { default as HealthCheck } from './interfaces/HealthCheck'; + +export { default as HealthManager } from './HealthManager'; diff --git a/packages/health/src/interfaces/HealthCheck.ts b/packages/health/src/interfaces/HealthCheck.ts new file mode 100644 index 00000000..b59c67bd --- /dev/null +++ b/packages/health/src/interfaces/HealthCheck.ts @@ -0,0 +1,11 @@ + +interface HealthCheck +{ + get name(): string; + + get timeout(): number | undefined; + + isHealthy(): Promise; +} + +export default HealthCheck; diff --git a/packages/health/tsconfig.json b/packages/health/tsconfig.json new file mode 100644 index 00000000..e66fac12 --- /dev/null +++ b/packages/health/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "rootDir": "./src/", + "moduleResolution": "node", + "declaration": true, + "outDir": "./dist", + "removeComments": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "exclude": [ + "vite.config.ts", + "node_modules", + "dist", + "test" + ] +} \ No newline at end of file diff --git a/packages/health/vite.config.ts b/packages/health/vite.config.ts new file mode 100644 index 00000000..f5182e1f --- /dev/null +++ b/packages/health/vite.config.ts @@ -0,0 +1,10 @@ +// vite.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8' + }, + }, +}); diff --git a/packages/middleware/CHANGELOG.md b/packages/middleware/CHANGELOG.md new file mode 100644 index 00000000..80f29432 --- /dev/null +++ b/packages/middleware/CHANGELOG.md @@ -0,0 +1,4 @@ + +# Changelog + +This package doesn't keep a changelog. See the changelog in the [github repository](https://github.com/MaskingTechnology/jitar/blob/main/CHANGELOG.md) \ No newline at end of file diff --git a/packages/middleware/README.md b/packages/middleware/README.md new file mode 100644 index 00000000..d8753c92 --- /dev/null +++ b/packages/middleware/README.md @@ -0,0 +1,9 @@ + +# Jitar Middleware + +This package contains the components for middleware support for the [Jitar](https://jitar.dev) runtime. + +For more information about Jitar: + +* [Visit our website](https://jitar.dev) +* [Read the documentation](https://docs.jitar.dev). diff --git a/packages/middleware/package.json b/packages/middleware/package.json new file mode 100644 index 00000000..578cf12a --- /dev/null +++ b/packages/middleware/package.json @@ -0,0 +1,45 @@ +{ + "name": "@jitar/middleware", + "version": "0.7.4", + "description": "Middleware library for the Jitar runtime.", + "author": "Masking Technology (https://jitar.dev)", + "license": "MIT", + "type": "module", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "CHANGELOG.md", + "README.md", + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "vitest run", + "test-coverage": "vitest run --coverage", + "lint": "eslint . --ext .ts", + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "@jitar/errors": "*", + "@jitar/execution": "*", + "@jitar/sourcing": "*" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/MaskingTechnology/jitar.git" + }, + "bugs": { + "url": "https://github.com/MaskingTechnology/jitar/issues" + }, + "homepage": "https://jitar.dev", + "keywords": [ + "configuration", + "jitar" + ] +} diff --git a/packages/middleware/src/MiddlewareManager.ts b/packages/middleware/src/MiddlewareManager.ts new file mode 100644 index 00000000..fc3a6692 --- /dev/null +++ b/packages/middleware/src/MiddlewareManager.ts @@ -0,0 +1,72 @@ + +import { Request, Response } from '@jitar/execution'; +import { SourcingManager } from '@jitar/sourcing'; + +import InvalidMiddleware from './errors/InvalidMiddleware'; +import Middleware from './interfaces/Middleware'; +import NextHandler from './types/NextHandler'; + +export default class MiddlewareManager +{ + #sourcingManager: SourcingManager; + #middlewares: Middleware[] = []; + + constructor(sourcingManager: SourcingManager) + { + this.#sourcingManager = sourcingManager; + } + + async importMiddleware(filename: string): Promise + { + const module = await this.#sourcingManager.import(filename); + const middleware = module.default as Middleware; + + if (middleware?.handle === undefined) + { + throw new InvalidMiddleware(filename); + } + + this.addMiddleware(middleware); + } + + addMiddleware(middleware: Middleware): void + { + // We want to add the middleware before the ProcedureRunner because + // it is the last middleware that needs to be called. + + const index = this.#middlewares.length - 1; + + this.#middlewares.splice(index, 0, middleware); + } + + getMiddleware(type: Function): Middleware | undefined + { + return this.#middlewares.find(middleware => middleware instanceof type); + } + + clearMiddlewares(): void + { + this.#middlewares = []; + } + + handle(request: Request): Promise + { + const startHandler = this.#getNextHandler(request, 0); + + return startHandler(); + } + + #getNextHandler(request: Request, index: number): NextHandler + { + const next = this.#middlewares[index]; + + if (next === undefined) + { + return async () => new Response(); + } + + const nextHandler = this.#getNextHandler(request, index + 1); + + return async () => { return next.handle(request, nextHandler); }; + } +} diff --git a/packages/middleware/src/errors/InvalidMiddleware.ts b/packages/middleware/src/errors/InvalidMiddleware.ts new file mode 100644 index 00000000..37fe5114 --- /dev/null +++ b/packages/middleware/src/errors/InvalidMiddleware.ts @@ -0,0 +1,19 @@ + +import { ServerError } from '@jitar/errors'; +import { Loadable } from '@jitar/serialization'; + +export default class InvalidMiddleware extends ServerError +{ + #url: string; + + constructor(url: string) + { + super(`Module '${url}' does not export valid middleware`); + + this.#url = url; + } + + get url() { return this.#url; } +} + +(InvalidMiddleware as Loadable).source = 'RUNTIME_ERROR_LOCATION'; diff --git a/packages/middleware/src/implementations/ProcedureRunner.ts b/packages/middleware/src/implementations/ProcedureRunner.ts new file mode 100644 index 00000000..9b068acd --- /dev/null +++ b/packages/middleware/src/implementations/ProcedureRunner.ts @@ -0,0 +1,21 @@ + +import{ Runner, Request, Response } from '@jitar/execution'; + +import Middleware from '../interfaces/Middleware'; +import NextHandler from '../types/NextHandler'; + +export default class ProcedureRunner implements Middleware +{ + #runner: Runner; + + constructor(runner: Runner) + { + this.#runner = runner; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async handle(request: Request, next: NextHandler): Promise + { + return this.#runner.run(request); + } +} diff --git a/packages/middleware/src/index.ts b/packages/middleware/src/index.ts new file mode 100644 index 00000000..718ef493 --- /dev/null +++ b/packages/middleware/src/index.ts @@ -0,0 +1,8 @@ + +export { default as Middleware } from './interfaces/Middleware'; + +export { default as ProcedureRunner } from './implementations/ProcedureRunner'; + +export { default as NextHandler } from './types/NextHandler'; + +export { default as MiddlewareManager } from './MiddlewareManager'; diff --git a/packages/middleware/src/interfaces/Middleware.ts b/packages/middleware/src/interfaces/Middleware.ts new file mode 100644 index 00000000..02b6b2f7 --- /dev/null +++ b/packages/middleware/src/interfaces/Middleware.ts @@ -0,0 +1,11 @@ + +import type { Request, Response } from '@jitar/execution'; + +import NextHandler from '../types/NextHandler.js'; + +interface Middleware +{ + handle(request: Request, next: NextHandler): Promise; +} + +export default Middleware; diff --git a/packages/middleware/src/types/NextHandler.ts b/packages/middleware/src/types/NextHandler.ts new file mode 100644 index 00000000..92c32206 --- /dev/null +++ b/packages/middleware/src/types/NextHandler.ts @@ -0,0 +1,6 @@ + +import { Response } from '@jitar/execution'; + +type NextHandler = () => Promise; + +export default NextHandler; diff --git a/packages/middleware/tsconfig.json b/packages/middleware/tsconfig.json new file mode 100644 index 00000000..e66fac12 --- /dev/null +++ b/packages/middleware/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "rootDir": "./src/", + "moduleResolution": "node", + "declaration": true, + "outDir": "./dist", + "removeComments": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "exclude": [ + "vite.config.ts", + "node_modules", + "dist", + "test" + ] +} \ No newline at end of file diff --git a/packages/middleware/vite.config.ts b/packages/middleware/vite.config.ts new file mode 100644 index 00000000..f5182e1f --- /dev/null +++ b/packages/middleware/vite.config.ts @@ -0,0 +1,10 @@ +// vite.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8' + }, + }, +}); diff --git a/packages/services/CHANGELOG.md b/packages/services/CHANGELOG.md new file mode 100644 index 00000000..80f29432 --- /dev/null +++ b/packages/services/CHANGELOG.md @@ -0,0 +1,4 @@ + +# Changelog + +This package doesn't keep a changelog. See the changelog in the [github repository](https://github.com/MaskingTechnology/jitar/blob/main/CHANGELOG.md) \ No newline at end of file diff --git a/packages/services/README.md b/packages/services/README.md new file mode 100644 index 00000000..04652574 --- /dev/null +++ b/packages/services/README.md @@ -0,0 +1,9 @@ + +# Jitar Health + +This package contains the services of the [Jitar](https://jitar.dev) runtime. + +For more information about Jitar: + +* [Visit our website](https://jitar.dev) +* [Read the documentation](https://docs.jitar.dev). diff --git a/packages/services/package.json b/packages/services/package.json new file mode 100644 index 00000000..f385634c --- /dev/null +++ b/packages/services/package.json @@ -0,0 +1,47 @@ +{ + "name": "@jitar/services", + "version": "0.7.4", + "description": "Services library for the Jitar runtime.", + "author": "Masking Technology (https://jitar.dev)", + "license": "MIT", + "type": "module", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "CHANGELOG.md", + "README.md", + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "vitest run", + "test-coverage": "vitest run --coverage", + "lint": "eslint . --ext .ts", + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "@jitar/errors": "*", + "@jitar/execution": "*", + "@jitar/health": "*", + "@jitar/middleware": "*", + "@jitar/sourcing": "*" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/MaskingTechnology/jitar.git" + }, + "bugs": { + "url": "https://github.com/MaskingTechnology/jitar/issues" + }, + "homepage": "https://jitar.dev", + "keywords": [ + "configuration", + "jitar" + ] +} diff --git a/packages/services/src/Client.ts b/packages/services/src/Client.ts new file mode 100644 index 00000000..2c450aa2 --- /dev/null +++ b/packages/services/src/Client.ts @@ -0,0 +1,55 @@ + +import { Request, Response, ExecutionManager, Runner } from '@jitar/execution'; +import { MiddlewareManager, ProcedureRunner } from '@jitar/middleware'; + +import RemoteGateway from './gateway/RemoteGateway'; + +type Configuration = +{ + gateway: RemoteGateway; + middlewareManager: MiddlewareManager; // object with all middleware loaded + executionManager: ExecutionManager; // object with all segments loaded +}; + +export default class Client implements Runner +{ + #gateway: RemoteGateway; + + #middlewareManager: MiddlewareManager; + #executionManager: ExecutionManager; + + constructor(configuration: Configuration) + { + this.#gateway = configuration.gateway; + + this.#middlewareManager = configuration.middlewareManager; + this.#executionManager = configuration.executionManager; + + // TODO: Should be done when constructing the middleware manager + this.#middlewareManager.addMiddleware(new ProcedureRunner(this.#executionManager)); + + // TODO: Move runtime registration to the starter script + } + + run(request: Request): Promise + { + return this.#mustRunLocal(request) + ? this.#runLocal(request) + : this.#runRemote(request); + } + + #mustRunLocal(request: Request): boolean + { + return this.#executionManager.hasProcedure(request.fqn); + } + + #runLocal(request: Request): Promise + { + return this.#middlewareManager.handle(request); + } + + #runRemote(request: Request): Promise + { + return this.#gateway.run(request); + } +} diff --git a/packages/services/src/Remote.ts b/packages/services/src/Remote.ts new file mode 100644 index 00000000..9447ddf9 --- /dev/null +++ b/packages/services/src/Remote.ts @@ -0,0 +1,157 @@ + +import { Request, Response as ResultResponse } from '@jitar/execution'; +import { Serializer } from '@jitar/serialization'; +import { File } from '@jitar/sourcing'; + +const APPLICATION_JSON = 'application/json'; + +export default class Remote +{ + #url: string; + #serializer: Serializer; + + constructor(url: string, serializer: Serializer) + { + this.#url = url; + this.#serializer = serializer; + } + + async loadFile(filename: string): Promise + { + const remoteUrl = `${this.#url}/${filename}`; + const options = { method: 'GET' }; + + const response = await this.#callRemote(remoteUrl, options); + const type = response.headers.get('Content-Type') || 'application/octet-stream'; + const content = await response.text(); + + return new File(filename, type, content); + } + + async isHealthy(): Promise + { + const remoteUrl = `${this.#url}/health/status`; + const options = { method: 'GET' }; + + const response = await this.#callRemote(remoteUrl, options); + const healthy = await response.text(); + + return Boolean(healthy); + } + + async getHealth(): Promise> + { + const remoteUrl = `${this.#url}/health`; + const options = { method: 'GET' }; + + const response = await this.#callRemote(remoteUrl, options); + const health = await response.json(); + + return new Map(Object.entries(health)); + } + + async addWorker(url: string, procedureNames: string[], trustKey?: string): Promise + { + const remoteUrl = `${this.#url}/workers`; + const body = { url, procedureNames, trustKey}; + const options = + { + method: 'POST', + headers: { 'Content-Type': APPLICATION_JSON }, + body: JSON.stringify(body) + }; + + await this.#callRemote(remoteUrl, options); + } + + async run(request: Request): Promise + { + request.setHeader('content-type', APPLICATION_JSON); + + const versionString = request.version.toString(); + const argsObject = Object.fromEntries(request.args); + const headersObject = Object.fromEntries(request.headers); + + const url = `${this.#url}/rpc/${request.fqn}?version=${versionString}&serialize=true`; + const body = await this.#createRequestBody(argsObject); + const options = + { + method: 'POST', + headers: headersObject, + body: body + }; + + const response = await this.#callRemote(url, options); + const result = await this.#createResponseResult(response); + const headers = this.#createResponseHeaders(response); + + return new ResultResponse(result, headers); + } + + async #callRemote(url: string, options: object): Promise + { + const response = await fetch(url, options); + + if (this.#isErrorResponse(response)) + { + throw await this.#createResponseResult(response); + } + + return response; + } + + #isErrorResponse(response: Response): boolean + { + return response.status < 200 || response.status > 299; + } + + async #createRequestBody(body: unknown): Promise + { + const data = await this.#serializer.serialize(body); + + return JSON.stringify(data); + } + + async #createResponseResult(response: Response): Promise + { + const result = await this.#getResponseResult(response); + + return this.#serializer.deserialize(result); + } + + async #getResponseResult(response: Response): Promise + { + const contentType = response.headers.get('Content-Type'); + + if (contentType !== null && contentType.includes('json')) + { + return response.json(); + } + + const content = await response.text(); + + if (contentType !== null && contentType.includes('boolean')) + { + return content === 'true'; + } + + if (contentType !== null && contentType.includes('number')) + { + return Number(content); + } + + return content; + } + + #createResponseHeaders(response: Response): Map + { + const headers = new Map(); + + for (const [name, value] of response.headers) + { + headers.set(name, value); + } + + return headers; + } +} diff --git a/packages/services/src/RunnerService.ts b/packages/services/src/RunnerService.ts new file mode 100644 index 00000000..3aff5150 --- /dev/null +++ b/packages/services/src/RunnerService.ts @@ -0,0 +1,11 @@ + +import { Runner } from '@jitar/execution'; + +import Service from './Service'; + +export default interface RunnerService extends Runner, Service +{ + getProcedureNames(): string[]; + + hasProcedure(name: string): boolean; +} diff --git a/packages/services/src/Service.ts b/packages/services/src/Service.ts new file mode 100644 index 00000000..ff6ac3d8 --- /dev/null +++ b/packages/services/src/Service.ts @@ -0,0 +1,13 @@ + +export default interface Service +{ + get url(): string; + + start(): Promise; + + stop(): Promise; + + isHealthy(): Promise; + + getHealth(): Promise> +} diff --git a/packages/services/src/gateway/Gateway.ts b/packages/services/src/gateway/Gateway.ts new file mode 100644 index 00000000..88392e85 --- /dev/null +++ b/packages/services/src/gateway/Gateway.ts @@ -0,0 +1,8 @@ + +import RunnerService from '../RunnerService'; +import Worker from '../worker/Worker'; + +export default interface Gateway extends RunnerService +{ + addWorker(worker: Worker): Promise; +} diff --git a/packages/services/src/gateway/LocalGateway.ts b/packages/services/src/gateway/LocalGateway.ts new file mode 100644 index 00000000..c731a4ca --- /dev/null +++ b/packages/services/src/gateway/LocalGateway.ts @@ -0,0 +1,96 @@ + +import type { Request, Response } from '@jitar/execution'; +import { HealthManager } from '@jitar/health'; +import { MiddlewareManager, ProcedureRunner } from '@jitar/middleware'; + +import Worker from '../worker/Worker'; + +import Gateway from './Gateway'; +import WorkerManager from './WorkerManager'; +import WorkerMonitor from './WorkerMonitor'; + +import InvalidTrustKey from './errors/InvalidTrustKey'; + +type Configuration = +{ + url: string; + trustKey?: string; + healthManager: HealthManager; // object with all health checks loaded + middlewareManager: MiddlewareManager; // object with all middleware loaded + monitorInterval?: number; +}; + +export default class LocalGateway implements Gateway +{ + #url: string; + #trustKey?: string; + #healthManager: HealthManager; + #middlewareManager: MiddlewareManager; + #workerManager: WorkerManager; + #workerMonitor: WorkerMonitor; + + constructor(configuration: Configuration) + { + this.#url = configuration.url; + this.#trustKey = configuration.trustKey; + this.#healthManager = configuration.healthManager; + this.#middlewareManager = configuration.middlewareManager; + this.#workerManager = new WorkerManager(); + this.#workerMonitor = new WorkerMonitor(this.#workerManager, configuration.monitorInterval); + + // TODO: Should be done when constructing the middleware manager + this.#middlewareManager.addMiddleware(new ProcedureRunner(this.#workerManager)); + } + + get url() { return this.#url; } + + async start(): Promise + { + return this.#workerMonitor.start(); + } + + async stop(): Promise + { + return this.#workerMonitor.stop(); + } + + isHealthy(): Promise + { + return this.#healthManager.isHealthy(); + } + + getHealth(): Promise> + { + return this.#healthManager.getHealth(); + } + + addWorker(worker: Worker, trustKey?: string): Promise + { + if (this.#isInvalidTrustKey(trustKey)) + { + throw new InvalidTrustKey(); + } + + return this.#workerManager.addWorker(worker); + } + + getProcedureNames(): string[] + { + return this.#workerManager.getProcedureNames(); + } + + hasProcedure(name: string): boolean + { + return this.#workerManager.hasProcedure(name); + } + + run(request: Request): Promise + { + return this.#middlewareManager.handle(request); + } + + #isInvalidTrustKey(trustKey?: string): boolean + { + return this.#trustKey !== undefined && trustKey !== this.#trustKey; + } +} diff --git a/packages/services/src/gateway/RemoteGateway.ts b/packages/services/src/gateway/RemoteGateway.ts new file mode 100644 index 00000000..bf96b00a --- /dev/null +++ b/packages/services/src/gateway/RemoteGateway.ts @@ -0,0 +1,69 @@ + +import { NotImplemented } from '@jitar/errors'; +import { Request, Response } from '@jitar/execution'; + +import Remote from '../Remote'; +import Worker from '../worker/Worker'; + +import Gateway from './Gateway'; + +type Configuration = +{ + url: string; + remote: Remote; +}; + +export default class RemoteGateway implements Gateway +{ + #url: string; + #remote: Remote; + + constructor(configuration: Configuration) + { + this.#url = configuration.url; + this.#remote = configuration.remote; + } + + get url() { return this.#url; } + + start(): Promise + { + return Promise.resolve(); + } + + stop(): Promise + { + return Promise.resolve(); + } + + isHealthy(): Promise + { + return this.#remote.isHealthy(); + } + + getHealth(): Promise> + { + return this.#remote.getHealth(); + } + + getProcedureNames(): string[] + { + throw new NotImplemented(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + hasProcedure(name: string): boolean + { + throw new NotImplemented(); + } + + addWorker(worker: Worker): Promise + { + return this.#remote.addWorker(worker.url, worker.getProcedureNames(), worker.trustKey); + } + + run(request: Request): Promise + { + return this.#remote.run(request); + } +} diff --git a/packages/services/src/gateway/WorkerBalancer.ts b/packages/services/src/gateway/WorkerBalancer.ts new file mode 100644 index 00000000..f2013dfe --- /dev/null +++ b/packages/services/src/gateway/WorkerBalancer.ts @@ -0,0 +1,61 @@ + +import type { Request, Response } from '@jitar/execution'; + +import type Worker from '../worker/Worker'; + +import NoWorkerAvailable from './errors/NoWorkerAvailable'; + +export default class WorkerBalancer +{ + #workers: Worker[] = []; + #currentIndex = 0; + + addWorker(worker: Worker): void + { + if (this.#workers.includes(worker)) + { + return; + } + + this.#workers.push(worker); + } + + removeWorker(worker: Worker): void + { + const index = this.#workers.indexOf(worker); + + if (index === -1) + { + return; + } + + this.#workers.splice(index, 1); + } + + getNextWorker(): Worker | undefined + { + if (this.#workers.length === 0) + { + return; + } + + if (this.#currentIndex >= this.#workers.length) + { + this.#currentIndex = 0; + } + + return this.#workers[this.#currentIndex++]; + } + + run(request: Request): Promise + { + const worker = this.getNextWorker(); + + if (worker === undefined) + { + throw new NoWorkerAvailable(request.fqn); + } + + return worker.run(request); + } +} diff --git a/packages/services/src/gateway/WorkerManager.ts b/packages/services/src/gateway/WorkerManager.ts new file mode 100644 index 00000000..84d4dcd2 --- /dev/null +++ b/packages/services/src/gateway/WorkerManager.ts @@ -0,0 +1,91 @@ + +import { Request, Response, Runner, ProcedureNotFound } from '@jitar/execution'; + +import Worker from '../worker/Worker'; +import WorkerBalancer from './WorkerBalancer'; + +export default class WorkerManager implements Runner +{ + #workers: Set = new Set(); + #balancers: Map = new Map(); + + get workers() + { + return [...this.#workers.values()]; + } + + getProcedureNames(): string[] + { + const procedureNames = this.workers.map(worker => worker.getProcedureNames()); + const uniqueNames = new Set(procedureNames.flat()); + + return [...uniqueNames.values()]; + } + + hasProcedure(fqn: string): boolean + { + const procedureNames = this.getProcedureNames(); + + return procedureNames.includes(fqn); + } + + async addWorker(worker: Worker, trustKey?: string): Promise + { + this.#workers.add(worker); + + for (const name of worker.getProcedureNames()) + { + const balancer = this.#getOrCreateBalancer(name); + + balancer.addWorker(worker); + } + } + + removeWorker(worker: Worker): void + { + this.#workers.delete(worker); + + for (const name of worker.getProcedureNames()) + { + const balancer = this.#getBalancer(name); + + if (balancer === undefined) + { + continue; + } + + balancer.removeWorker(worker); + } + } + + #getBalancer(fqn: string): WorkerBalancer | undefined + { + return this.#balancers.get(fqn); + } + + #getOrCreateBalancer(fqn: string): WorkerBalancer + { + let balancer = this.#getBalancer(fqn); + + if (balancer === undefined) + { + balancer = new WorkerBalancer(); + + this.#balancers.set(fqn, balancer); + } + + return balancer; + } + + run(request: Request): Promise + { + const balancer = this.#getBalancer(request.fqn); + + if (balancer === undefined) + { + throw new ProcedureNotFound(request.fqn); + } + + return balancer.run(request); + } +} diff --git a/packages/services/src/gateway/WorkerMonitor.ts b/packages/services/src/gateway/WorkerMonitor.ts new file mode 100644 index 00000000..2cd087cf --- /dev/null +++ b/packages/services/src/gateway/WorkerMonitor.ts @@ -0,0 +1,64 @@ + +import type Worker from '../worker/Worker'; + +import type WorkerManager from './WorkerManager'; + +const DEFAULT_FREQUENCY = 5000; + +export default class WorkerMonitor +{ + #workerManager: WorkerManager; + #frequency: number; + #interval: ReturnType | null = null; + + constructor(workerManager: WorkerManager, frequency = DEFAULT_FREQUENCY) + { + this.#workerManager = workerManager; + this.#frequency = frequency; + } + + start(): void + { + this.#interval = setInterval(async () => this.#monitor(), this.#frequency); + } + + stop(): void + { + if (this.#interval === null) + { + return; + } + + clearInterval(this.#interval); + } + + async #monitor(): Promise + { + const workers = this.#workerManager.workers; + const promises = workers.map(worker => this.#monitorWorker(worker)); + + await Promise.all(promises); + } + + async #monitorWorker(worker: Worker): Promise + { + const available = await this.#checkWorkerAvailable(worker); + + if (available === false) + { + this.#workerManager.removeWorker(worker); + } + } + + async #checkWorkerAvailable(worker: Worker): Promise + { + try + { + return await worker.isHealthy(); + } + catch (error) + { + return false; + } + } +} diff --git a/packages/services/src/gateway/errors/InvalidTrustKey.ts b/packages/services/src/gateway/errors/InvalidTrustKey.ts new file mode 100644 index 00000000..dece2845 --- /dev/null +++ b/packages/services/src/gateway/errors/InvalidTrustKey.ts @@ -0,0 +1,13 @@ + +import { Unauthorized } from '@jitar/errors'; +import { Loadable } from '@jitar/serialization'; + +export default class InvalidTrustKey extends Unauthorized +{ + constructor() + { + super(`Invalid trust key`); + } +} + +(InvalidTrustKey as Loadable).source = 'RUNTIME_ERROR_LOCATION'; diff --git a/packages/services/src/gateway/errors/NoWorkerAvailable.ts b/packages/services/src/gateway/errors/NoWorkerAvailable.ts new file mode 100644 index 00000000..227126fe --- /dev/null +++ b/packages/services/src/gateway/errors/NoWorkerAvailable.ts @@ -0,0 +1,19 @@ + +import { ServerError } from '@jitar/errors'; +import { Loadable } from '@jitar/serialization'; + +export default class NoWorkerAvailable extends ServerError +{ + #name: string; + + constructor(name: string) + { + super(`No worker available for procedure '${name}'`); + + this.#name = name; + } + + get name() { return this.#name; } +} + +(NoWorkerAvailable as Loadable).source = 'RUNTIME_ERROR_LOCATION'; diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts new file mode 100644 index 00000000..e865bcba --- /dev/null +++ b/packages/services/src/index.ts @@ -0,0 +1,20 @@ + +export { default as Gateway } from './gateway/Gateway'; +export { default as LocalGateway } from './gateway/LocalGateway'; +export { default as RemoteGateway } from './gateway/RemoteGateway'; + +export { default as Proxy } from './proxy/Proxy'; + +export { default as Repository } from './repository/Repository'; +export { default as LocalRepository } from './repository/LocalRepository'; +export { default as RemoteRepository } from './repository/RemoteRepository'; + +export { default as Worker } from './worker/Worker'; +export { default as LocalWorker } from './worker/LocalWorker'; +export { default as RemoteWorker } from './worker/RemoteWorker'; + +export { default as Client } from './Client'; +export { default as Remote } from './Remote'; + +export { default as Service } from './Service'; +export { default as RunnerService } from './RunnerService'; diff --git a/packages/services/src/proxy/Proxy.ts b/packages/services/src/proxy/Proxy.ts new file mode 100644 index 00000000..5985a083 --- /dev/null +++ b/packages/services/src/proxy/Proxy.ts @@ -0,0 +1,80 @@ + +import type { Request, Response } from '@jitar/execution'; +import type { File } from '@jitar/sourcing'; + +import Repository from '../repository/Repository'; + +import RunnerService from '../RunnerService'; + +type Configuration = +{ + url: string; + repository: Repository; + runner: RunnerService; +}; + +export default class Proxy implements Repository, RunnerService +{ + #url: string; + #repository: Repository; + #runner: RunnerService; + + constructor(configuration: Configuration) + { + this.#url = configuration.url; + this.#repository = configuration.repository; + this.#runner = configuration.runner; + } + + get url() { return this.#url; } + + isHealthy(): Promise + { + throw new Error('Method not implemented.'); + } + + getHealth(): Promise> + { + throw new Error('Method not implemented.'); + } + + get repository() { return this.#repository; } + + get runner() { return this.#runner; } + + async start(): Promise + { + await Promise.all([ + this.#repository.start(), + this.#runner.start() + ]); + } + + async stop(): Promise + { + await Promise.all([ + this.#runner.stop(), + this.#repository.stop() + ]); + } + + readAsset(filename: string): Promise + { + return this.#repository.readAsset(filename); + } + + getProcedureNames(): string[] + { + return this.#runner.getProcedureNames(); + } + + hasProcedure(fqn: string): boolean + { + return this.#runner.hasProcedure(fqn); + } + + run(request: Request): Promise + { + return this.#runner.run(request); + } +} diff --git a/packages/services/src/repository/LocalRepository.ts b/packages/services/src/repository/LocalRepository.ts new file mode 100644 index 00000000..93419aaa --- /dev/null +++ b/packages/services/src/repository/LocalRepository.ts @@ -0,0 +1,57 @@ + +import { File, FileNotFound, SourcingManager } from '@jitar/sourcing'; + +import Repository from './Repository.js'; + +type Configuration = +{ + url: string; + assets: Set; + sourcingManager: SourcingManager; +}; + +export default class LocalRepository implements Repository +{ + #url: string; + #sourcingManager: SourcingManager; + #assets: Set; + + constructor(configuration: Configuration) + { + this.#url = configuration.url; + this.#sourcingManager = configuration.sourcingManager; + this.#assets = configuration.assets; + } + + get url() { return this.#url; } + + start(): Promise + { + return Promise.resolve(); + } + + stop(): Promise + { + return Promise.resolve(); + } + + async isHealthy(): Promise + { + return true; + } + + async getHealth(): Promise> + { + return new Map(); + } + + readAsset(filename: string): Promise + { + if (this.#assets.has(filename) === false) + { + throw new FileNotFound(filename); + } + + return this.#sourcingManager.read(filename); + } +} diff --git a/packages/services/src/repository/RemoteRepository.ts b/packages/services/src/repository/RemoteRepository.ts new file mode 100644 index 00000000..0c04e411 --- /dev/null +++ b/packages/services/src/repository/RemoteRepository.ts @@ -0,0 +1,51 @@ + +import { File } from '@jitar/sourcing'; + +import Remote from '../Remote'; + +import Repository from './Repository'; + +type Configuration = +{ + url: string; + remote: Remote; +}; + +export default class RemoteRepository implements Repository +{ + #url: string + #remote: Remote; + + constructor(configuration: Configuration) + { + this.#url = configuration.url; + this.#remote = configuration.remote; + } + + get url() { return this.#url; } + + start(): Promise + { + return Promise.resolve(); + } + + stop(): Promise + { + return Promise.resolve(); + } + + isHealthy(): Promise + { + return this.#remote.isHealthy(); + } + + getHealth(): Promise> + { + return this.#remote.getHealth(); + } + + readAsset(filename: string): Promise + { + return this.#remote.loadFile(filename); + } +} diff --git a/packages/services/src/repository/Repository.ts b/packages/services/src/repository/Repository.ts new file mode 100644 index 00000000..08495ad2 --- /dev/null +++ b/packages/services/src/repository/Repository.ts @@ -0,0 +1,9 @@ + +import { File } from '@jitar/sourcing'; + +import Service from '../Service'; + +export default interface Repository extends Service +{ + readAsset(filename: string): Promise; +} diff --git a/packages/services/src/worker/LocalWorker.ts b/packages/services/src/worker/LocalWorker.ts new file mode 100644 index 00000000..2dcffb11 --- /dev/null +++ b/packages/services/src/worker/LocalWorker.ts @@ -0,0 +1,133 @@ + +import { Request, Response, ExecutionManager } from '@jitar/execution'; +import { Unauthorized } from '@jitar/errors'; +import { HealthManager } from '@jitar/health'; +import { MiddlewareManager, ProcedureRunner } from '@jitar/middleware'; + +import Gateway from '../gateway/Gateway'; +import Worker from './Worker'; + +import InvalidTrustKey from './errors/InvalidTrustKey'; + +const JITAR_TRUST_HEADER_KEY = 'X-Jitar-Trust-Key'; + +type Configuration = +{ + url: string; + trustKey?: string; + gateway?: Gateway; + healthManager: HealthManager; // object with all health checks loaded + middlewareManager: MiddlewareManager; // object with all middleware loaded + executionManager: ExecutionManager; // object with all segments loaded +}; + +export default class LocalWorker implements Worker +{ + #url: string; + #trustKey?: string; + #gateway?: Gateway; + + #healthManager: HealthManager; + #middlewareManager: MiddlewareManager; + #executionManager: ExecutionManager; + + constructor(configuration: Configuration) + { + this.#url = configuration.url; + this.#trustKey = configuration.trustKey; + this.#gateway = configuration.gateway; + + this.#healthManager = configuration.healthManager; + this.#middlewareManager = configuration.middlewareManager; + this.#executionManager = configuration.executionManager; + + // TODO: Should be done when constructing the middleware manager + this.#middlewareManager.addMiddleware(new ProcedureRunner(this.#executionManager)); + + // TODO: Move runtime registration to the starter script + } + + get url() { return this.#url; } + + get trustKey() { return this.#trustKey; } + + async start(): Promise + { + if (this.#gateway !== undefined) + { + await this.#gateway.start(); + await this.#gateway.addWorker(this); + } + } + + async stop(): Promise + { + if (this.#gateway !== undefined) + { + // TODO: Remove worker from gateway (Github issue #410) + await this.#gateway.stop(); + } + } + + getProcedureNames(): string[] + { + return this.#executionManager.getProcedureNames(); + } + + hasProcedure(name: string): boolean + { + return this.#executionManager.hasProcedure(name); + } + + isHealthy(): Promise + { + return this.#healthManager.isHealthy(); + } + + getHealth(): Promise> + { + return this.#healthManager.getHealth(); + } + + run(request: Request): Promise + { + return this.#mustRunLocal(request) + ? this.#runLocal(request) + : this.#runRemote(request); + } + + #mustRunLocal(request: Request): boolean + { + return this.#gateway === undefined + || this.#executionManager.hasProcedure(request.fqn); + } + + #runLocal(request: Request): Promise + { + const trustKey = request.getHeader(JITAR_TRUST_HEADER_KEY); + + if (trustKey !== undefined && this.#trustKey !== trustKey) + { + throw new InvalidTrustKey(); + } + + const procedure = this.#executionManager.getProcedure(request.fqn); + + if (trustKey === undefined && procedure!.protected) + { + throw new Unauthorized(); + } + + return this.#middlewareManager.handle(request); + } + + #runRemote(request: Request): Promise + { + if (this.#trustKey !== undefined) + { + request.headers.set(JITAR_TRUST_HEADER_KEY, this.#trustKey); + } + + return this.#gateway!.run(request); + } +} diff --git a/packages/services/src/worker/RemoteWorker.ts b/packages/services/src/worker/RemoteWorker.ts new file mode 100644 index 00000000..3ab4b73c --- /dev/null +++ b/packages/services/src/worker/RemoteWorker.ts @@ -0,0 +1,66 @@ + +import { Request, Response } from '@jitar/execution'; + +import Remote from '../Remote'; + +import Worker from './Worker'; + +type Configuration = +{ + url: string; + procedureNames: Set; + remote: Remote; +}; + +export default class RemoteWorker implements Worker +{ + #url: string; + #procedureNames: Set; + #remote: Remote; + + constructor(configuration: Configuration) + { + this.#url = configuration.url; + this.#procedureNames = configuration.procedureNames; + this.#remote = configuration.remote; + } + + get url() { return this.#url; } + + get trustKey() { return undefined; } + + start(): Promise + { + return Promise.resolve(); + } + + stop(): Promise + { + return Promise.resolve(); + } + + getProcedureNames(): string[] + { + return [...this.#procedureNames.values()]; + } + + hasProcedure(name: string): boolean + { + return this.#procedureNames.has(name); + } + + isHealthy(): Promise + { + return this.#remote.isHealthy(); + } + + getHealth(): Promise> + { + return this.#remote.getHealth(); + } + + run(request: Request): Promise + { + return this.#remote.run(request); + } +} diff --git a/packages/services/src/worker/Worker.ts b/packages/services/src/worker/Worker.ts new file mode 100644 index 00000000..78e263d3 --- /dev/null +++ b/packages/services/src/worker/Worker.ts @@ -0,0 +1,7 @@ + +import RunnerService from '../RunnerService'; + +export default interface Worker extends RunnerService +{ + get trustKey(): string | undefined; +} diff --git a/packages/services/src/worker/errors/InvalidTrustKey.ts b/packages/services/src/worker/errors/InvalidTrustKey.ts new file mode 100644 index 00000000..dece2845 --- /dev/null +++ b/packages/services/src/worker/errors/InvalidTrustKey.ts @@ -0,0 +1,13 @@ + +import { Unauthorized } from '@jitar/errors'; +import { Loadable } from '@jitar/serialization'; + +export default class InvalidTrustKey extends Unauthorized +{ + constructor() + { + super(`Invalid trust key`); + } +} + +(InvalidTrustKey as Loadable).source = 'RUNTIME_ERROR_LOCATION'; diff --git a/packages/services/tsconfig.json b/packages/services/tsconfig.json new file mode 100644 index 00000000..e66fac12 --- /dev/null +++ b/packages/services/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "rootDir": "./src/", + "moduleResolution": "node", + "declaration": true, + "outDir": "./dist", + "removeComments": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "exclude": [ + "vite.config.ts", + "node_modules", + "dist", + "test" + ] +} \ No newline at end of file diff --git a/packages/services/vite.config.ts b/packages/services/vite.config.ts new file mode 100644 index 00000000..f5182e1f --- /dev/null +++ b/packages/services/vite.config.ts @@ -0,0 +1,10 @@ +// vite.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8' + }, + }, +});