@openapi-typescript-infra/service 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintignore +7 -0
- package/.eslintrc.js +14 -0
- package/.github/workflows/codeql-analysis.yml +74 -0
- package/.github/workflows/nodejs.yml +23 -0
- package/.github/workflows/npmpublish.yml +35 -0
- package/.husky/pre-commit +6 -0
- package/.prettierrc.js +14 -0
- package/@types/config.d.ts +56 -0
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/SECURITY.md +12 -0
- package/__tests__/config.test.ts +31 -0
- package/__tests__/fake-serv/api/fake-serv.yaml +48 -0
- package/__tests__/fake-serv/config/config.json +15 -0
- package/__tests__/fake-serv/src/handlers/hello.ts +10 -0
- package/__tests__/fake-serv/src/index.ts +29 -0
- package/__tests__/fake-serv/src/routes/error.ts +13 -0
- package/__tests__/fake-serv/src/routes/index.ts +22 -0
- package/__tests__/fake-serv/src/routes/other/world.ts +7 -0
- package/__tests__/fake-serv.test.ts +74 -0
- package/build/bin/start-service.d.ts +2 -0
- package/build/bin/start-service.js +31 -0
- package/build/bin/start-service.js.map +1 -0
- package/build/bootstrap.d.ts +16 -0
- package/build/bootstrap.js +90 -0
- package/build/bootstrap.js.map +1 -0
- package/build/config/index.d.ts +10 -0
- package/build/config/index.js +98 -0
- package/build/config/index.js.map +1 -0
- package/build/config/schema.d.ts +48 -0
- package/build/config/schema.js +3 -0
- package/build/config/schema.js.map +1 -0
- package/build/config/shortstops.d.ts +31 -0
- package/build/config/shortstops.js +109 -0
- package/build/config/shortstops.js.map +1 -0
- package/build/config/types.d.ts +3 -0
- package/build/config/types.js +3 -0
- package/build/config/types.js.map +1 -0
- package/build/development/port-finder.d.ts +1 -0
- package/build/development/port-finder.js +41 -0
- package/build/development/port-finder.js.map +1 -0
- package/build/development/repl.d.ts +2 -0
- package/build/development/repl.js +29 -0
- package/build/development/repl.js.map +1 -0
- package/build/env.d.ts +2 -0
- package/build/env.js +19 -0
- package/build/env.js.map +1 -0
- package/build/error.d.ts +25 -0
- package/build/error.js +28 -0
- package/build/error.js.map +1 -0
- package/build/express-app/app.d.ts +6 -0
- package/build/express-app/app.js +327 -0
- package/build/express-app/app.js.map +1 -0
- package/build/express-app/index.d.ts +2 -0
- package/build/express-app/index.js +19 -0
- package/build/express-app/index.js.map +1 -0
- package/build/express-app/internal-server.d.ts +3 -0
- package/build/express-app/internal-server.js +34 -0
- package/build/express-app/internal-server.js.map +1 -0
- package/build/express-app/route-loader.d.ts +2 -0
- package/build/express-app/route-loader.js +46 -0
- package/build/express-app/route-loader.js.map +1 -0
- package/build/express-app/types.d.ts +14 -0
- package/build/express-app/types.js +3 -0
- package/build/express-app/types.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.js +25 -0
- package/build/index.js.map +1 -0
- package/build/openapi.d.ts +5 -0
- package/build/openapi.js +78 -0
- package/build/openapi.js.map +1 -0
- package/build/service-calls/index.d.ts +16 -0
- package/build/service-calls/index.js +85 -0
- package/build/service-calls/index.js.map +1 -0
- package/build/telemetry/fetchInstrumentation.d.ts +50 -0
- package/build/telemetry/fetchInstrumentation.js +144 -0
- package/build/telemetry/fetchInstrumentation.js.map +1 -0
- package/build/telemetry/index.d.ts +6 -0
- package/build/telemetry/index.js +80 -0
- package/build/telemetry/index.js.map +1 -0
- package/build/telemetry/instrumentations.d.ts +29 -0
- package/build/telemetry/instrumentations.js +47 -0
- package/build/telemetry/instrumentations.js.map +1 -0
- package/build/telemetry/requestLogger.d.ts +6 -0
- package/build/telemetry/requestLogger.js +144 -0
- package/build/telemetry/requestLogger.js.map +1 -0
- package/build/tsconfig.build.tsbuildinfo +1 -0
- package/build/types.d.ts +77 -0
- package/build/types.js +3 -0
- package/build/types.js.map +1 -0
- package/config/config.json +31 -0
- package/config/development.json +11 -0
- package/config/test.json +5 -0
- package/jest.config.js +14 -0
- package/package.json +111 -0
- package/src/bin/start-service.ts +28 -0
- package/src/bootstrap.ts +112 -0
- package/src/config/index.ts +115 -0
- package/src/config/schema.ts +66 -0
- package/src/config/shortstops.ts +118 -0
- package/src/config/types.ts +5 -0
- package/src/development/port-finder.ts +40 -0
- package/src/development/repl.ts +24 -0
- package/src/env.ts +14 -0
- package/src/error.ts +44 -0
- package/src/express-app/app.ts +399 -0
- package/src/express-app/index.ts +2 -0
- package/src/express-app/internal-server.ts +31 -0
- package/src/express-app/route-loader.ts +48 -0
- package/src/express-app/types.ts +31 -0
- package/src/index.ts +8 -0
- package/src/openapi.ts +67 -0
- package/src/service-calls/index.ts +129 -0
- package/src/telemetry/fetchInstrumentation.ts +209 -0
- package/src/telemetry/index.ts +69 -0
- package/src/telemetry/instrumentations.ts +54 -0
- package/src/telemetry/requestLogger.ts +193 -0
- package/src/types.ts +139 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +36 -0
package/package.json
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openapi-typescript-infra/service",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "An opinionated framework for building configuration driven services - web, api, or job. Uses OpenAPI, pino logging, express, confit, Typescript and Jest.",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "jest",
|
|
8
|
+
"lint": "eslint .",
|
|
9
|
+
"build": "tsc -p tsconfig.build.json && yarn dlx chmodx build/bin/*",
|
|
10
|
+
"watch": "tsc -p tsconfig.json -w --preserveWatchOutput",
|
|
11
|
+
"clean": "npx rimraf ./build",
|
|
12
|
+
"prepublishOnly": "yarn build",
|
|
13
|
+
"prepack": "pinst --disable",
|
|
14
|
+
"postpack": "pinst --enable",
|
|
15
|
+
"_postinstall": "husky install && coconfig"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/openapi-typescript-infra/service.git"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"start-service": "./build/bin/start-service.js"
|
|
23
|
+
},
|
|
24
|
+
"config": {
|
|
25
|
+
"coconfig": "@openapi-typescript-infra/coconfig"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"lint-staged": {
|
|
31
|
+
"*.{js,jsx,ts,tsx}": "yarn eslint --cache --fix"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"service",
|
|
35
|
+
"openapi",
|
|
36
|
+
"express",
|
|
37
|
+
"confit",
|
|
38
|
+
"babel",
|
|
39
|
+
"typescript",
|
|
40
|
+
"jest"
|
|
41
|
+
],
|
|
42
|
+
"author": "developers@pyralis.com>",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/openapi-typescript-infra/service/issues"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/openapi-typescript-infra/service#readme",
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@gasbuddy/confit": "^3.0.0",
|
|
50
|
+
"@godaddy/terminus": "^4.12.0",
|
|
51
|
+
"@opentelemetry/api": "^1.4.1",
|
|
52
|
+
"@opentelemetry/api-metrics": "^0.33.0",
|
|
53
|
+
"@opentelemetry/exporter-prometheus": "^0.39.1",
|
|
54
|
+
"@opentelemetry/exporter-trace-otlp-proto": "^0.39.1",
|
|
55
|
+
"@opentelemetry/instrumentation": "^0.39.1",
|
|
56
|
+
"@opentelemetry/instrumentation-aws-sdk": "^0.34.2",
|
|
57
|
+
"@opentelemetry/instrumentation-dns": "^0.31.4",
|
|
58
|
+
"@opentelemetry/instrumentation-express": "^0.32.3",
|
|
59
|
+
"@opentelemetry/instrumentation-generic-pool": "^0.31.3",
|
|
60
|
+
"@opentelemetry/instrumentation-graphql": "^0.34.2",
|
|
61
|
+
"@opentelemetry/instrumentation-http": "^0.39.1",
|
|
62
|
+
"@opentelemetry/instrumentation-ioredis": "^0.34.2",
|
|
63
|
+
"@opentelemetry/instrumentation-net": "^0.31.3",
|
|
64
|
+
"@opentelemetry/instrumentation-pg": "^0.35.2",
|
|
65
|
+
"@opentelemetry/instrumentation-pino": "^0.33.3",
|
|
66
|
+
"@opentelemetry/sdk-metrics": "^1.13.0",
|
|
67
|
+
"@opentelemetry/sdk-node": "^0.39.1",
|
|
68
|
+
"@opentelemetry/semantic-conventions": "^1.13.0",
|
|
69
|
+
"cookie-parser": "^1.4.6",
|
|
70
|
+
"dotenv": "^16.0.3",
|
|
71
|
+
"eventsource": "^1.1.2",
|
|
72
|
+
"express": "next",
|
|
73
|
+
"express-openapi-validator": "^5.0.4",
|
|
74
|
+
"glob": "^8.1.0",
|
|
75
|
+
"lodash": "^4.17.21",
|
|
76
|
+
"minimist": "^1.2.8",
|
|
77
|
+
"pino": "^8.14.1",
|
|
78
|
+
"read-pkg-up": "^7.0.1",
|
|
79
|
+
"rest-api-support": "^1.16.3",
|
|
80
|
+
"shortstop-dns": "^1.1.0",
|
|
81
|
+
"shortstop-handlers": "^1.1.1",
|
|
82
|
+
"shortstop-yaml": "^1.0.0"
|
|
83
|
+
},
|
|
84
|
+
"devDependencies": {
|
|
85
|
+
"@openapi-typescript-infra/coconfig": "^1.0.0",
|
|
86
|
+
"@types/cookie-parser": "^1.4.3",
|
|
87
|
+
"@types/eventsource": "1.1.11",
|
|
88
|
+
"@types/express": "^4.17.17",
|
|
89
|
+
"@types/glob": "^8.1.0",
|
|
90
|
+
"@types/jest": "^29.5.1",
|
|
91
|
+
"@types/lodash": "^4.14.194",
|
|
92
|
+
"@types/minimist": "^1.2.2",
|
|
93
|
+
"@types/node": "^18.16.14",
|
|
94
|
+
"@types/supertest": "^2.0.12",
|
|
95
|
+
"coconfig": "^0.10.1",
|
|
96
|
+
"eslint": "^8.41.0",
|
|
97
|
+
"eslint-config-gasbuddy": "^7.2.0",
|
|
98
|
+
"eslint-config-prettier": "^8.8.0",
|
|
99
|
+
"eslint-plugin-jest": "^27.2.1",
|
|
100
|
+
"husky": "^8.0.3",
|
|
101
|
+
"jest": "^29.5.0",
|
|
102
|
+
"lint-staged": "^13.2.2",
|
|
103
|
+
"pino-pretty": "^10.0.0",
|
|
104
|
+
"pinst": "^3.0.0",
|
|
105
|
+
"supertest": "^6.3.3",
|
|
106
|
+
"ts-jest": "^29.1.0",
|
|
107
|
+
"ts-node": "^10.9.1",
|
|
108
|
+
"typescript": "^5.0.4"
|
|
109
|
+
},
|
|
110
|
+
"packageManager": "yarn@3.2.3"
|
|
111
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import minimist from 'minimist';
|
|
3
|
+
|
|
4
|
+
import { serviceRepl } from '../development/repl';
|
|
5
|
+
import { isDev } from '../env';
|
|
6
|
+
import { bootstrap } from '../bootstrap';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* built - forces the use of the build directory. Defaults to true in stage/prod, not in dev
|
|
10
|
+
* repl - launch the REPL (defaults to disabling telemetry)
|
|
11
|
+
* telemetry - whether to use OpenTelemetry. Defaults to false in dev or with repl
|
|
12
|
+
* nobind - do not listen on http port or expose metrics
|
|
13
|
+
*/
|
|
14
|
+
const argv = minimist(process.argv.slice(2), {
|
|
15
|
+
boolean: ['built', 'repl', 'telemetry', 'nobind'],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const noTelemetry = (argv.repl || isDev()) && !argv.telemetry;
|
|
19
|
+
bootstrap({
|
|
20
|
+
...argv,
|
|
21
|
+
telemetry: !noTelemetry,
|
|
22
|
+
}).then(({ app, server }) => {
|
|
23
|
+
if (argv.repl) {
|
|
24
|
+
serviceRepl(app, () => {
|
|
25
|
+
server?.close();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
});
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import readPackageUp from 'read-pkg-up';
|
|
5
|
+
import type { NormalizedPackageJson } from 'read-pkg-up';
|
|
6
|
+
|
|
7
|
+
import type { RequestLocals, ServiceLocals, ServiceStartOptions } from './types';
|
|
8
|
+
import { isDev } from './env';
|
|
9
|
+
import { startWithTelemetry } from './telemetry/index';
|
|
10
|
+
|
|
11
|
+
interface BootstrapArguments {
|
|
12
|
+
// The name of the service, else discovered via read-pkg-up
|
|
13
|
+
name?: string;
|
|
14
|
+
// The name of the file with the service function, relative to root
|
|
15
|
+
main?: string;
|
|
16
|
+
// Root directory of the app, else discovered via read-pkg-up
|
|
17
|
+
root?: string;
|
|
18
|
+
// Use built directory. Omitting lets us determine a sensible default
|
|
19
|
+
built?: boolean;
|
|
20
|
+
// The location of the package.json used for discovery (defaults to cwd)
|
|
21
|
+
packageDir?: string;
|
|
22
|
+
// Whether to engage telemetry
|
|
23
|
+
telemetry?: boolean;
|
|
24
|
+
// Don't bind to http port or expose metrics
|
|
25
|
+
nobind?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveMain(packageJson: NormalizedPackageJson) {
|
|
29
|
+
if (typeof packageJson.main === 'string') {
|
|
30
|
+
return packageJson.main;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function getServiceDetails(argv: BootstrapArguments = {}) {
|
|
36
|
+
if (argv.name && argv.root) {
|
|
37
|
+
return {
|
|
38
|
+
rootDirectory: argv.root,
|
|
39
|
+
name: argv.name,
|
|
40
|
+
main: argv.main || (isDev() && !argv.built ? 'src/index.ts' : 'build/index.js'),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const cwd = argv.packageDir ? path.resolve(argv.packageDir) : process.cwd();
|
|
44
|
+
const pkg = await readPackageUp({ cwd });
|
|
45
|
+
if (!pkg) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Unable to find package.json in ${cwd} to get main module. Make sure you are running from the package root directory.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const main = resolveMain(pkg.packageJson);
|
|
51
|
+
const parts = pkg.packageJson.name.split('/');
|
|
52
|
+
return {
|
|
53
|
+
main,
|
|
54
|
+
rootDirectory: path.dirname(pkg.path),
|
|
55
|
+
name: parts[parts.length - 1],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Automagically start your app by using common patterns
|
|
60
|
+
// to find your implementation and settings. This is most useful
|
|
61
|
+
// for jobs or other scripts that need service infra but are
|
|
62
|
+
// not simply the service
|
|
63
|
+
export async function bootstrap<
|
|
64
|
+
SLocals extends ServiceLocals = ServiceLocals,
|
|
65
|
+
RLocals extends RequestLocals = RequestLocals,
|
|
66
|
+
>(argv?: BootstrapArguments) {
|
|
67
|
+
const { main, rootDirectory, name } = await getServiceDetails(argv);
|
|
68
|
+
|
|
69
|
+
let entrypoint: string;
|
|
70
|
+
let codepath: 'build' | 'src' = 'build';
|
|
71
|
+
if (isDev() && argv?.built !== true) {
|
|
72
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
73
|
+
const { register } = await import('ts-node');
|
|
74
|
+
register();
|
|
75
|
+
if (main) {
|
|
76
|
+
entrypoint = main.replace(/^(\.?\/?)build\//, '$1src/').replace(/\.js$/, '.ts');
|
|
77
|
+
} else {
|
|
78
|
+
entrypoint = './src/index.ts';
|
|
79
|
+
}
|
|
80
|
+
codepath = 'src';
|
|
81
|
+
} else if (main) {
|
|
82
|
+
entrypoint = main;
|
|
83
|
+
} else {
|
|
84
|
+
entrypoint = './build/index.js';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
dotenv.config();
|
|
88
|
+
|
|
89
|
+
const absoluteEntrypoint = path.resolve(rootDirectory, entrypoint);
|
|
90
|
+
if (argv?.telemetry) {
|
|
91
|
+
return startWithTelemetry<SLocals, RLocals>({
|
|
92
|
+
name,
|
|
93
|
+
rootDirectory,
|
|
94
|
+
service: absoluteEntrypoint,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// This needs to be required for TS on-the-fly to work
|
|
99
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require, @typescript-eslint/no-var-requires
|
|
100
|
+
const impl = require(absoluteEntrypoint);
|
|
101
|
+
const opts: ServiceStartOptions<SLocals, RLocals> = {
|
|
102
|
+
name,
|
|
103
|
+
rootDirectory,
|
|
104
|
+
service: impl.default || impl.service,
|
|
105
|
+
codepath,
|
|
106
|
+
};
|
|
107
|
+
// eslint-disable-next-line import/no-unresolved
|
|
108
|
+
const { startApp, listen } = await import('./express-app/app.js');
|
|
109
|
+
const app = await startApp<SLocals, RLocals>(opts);
|
|
110
|
+
const server = argv?.nobind ? undefined : await listen(app);
|
|
111
|
+
return { server, app };
|
|
112
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import confit from '@gasbuddy/confit';
|
|
5
|
+
|
|
6
|
+
import { shortstops } from './shortstops';
|
|
7
|
+
import type { ConfigStore } from './types';
|
|
8
|
+
|
|
9
|
+
// Order matters here.
|
|
10
|
+
const ENVIRONMENTS = ['production', 'staging', 'test', 'development'];
|
|
11
|
+
|
|
12
|
+
async function pathExists(f: string) {
|
|
13
|
+
return new Promise((accept, reject) => {
|
|
14
|
+
fs.stat(f, (err) => {
|
|
15
|
+
if (!err) {
|
|
16
|
+
accept(true);
|
|
17
|
+
} else if (err.code === 'ENOENT') {
|
|
18
|
+
accept(false);
|
|
19
|
+
} else {
|
|
20
|
+
reject(err);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function addDefaultConfiguration(
|
|
27
|
+
configFactory: ReturnType<typeof confit>,
|
|
28
|
+
directory: string,
|
|
29
|
+
envConfit: ConfigStore,
|
|
30
|
+
) {
|
|
31
|
+
const addIfEnv = async (e: string) => {
|
|
32
|
+
const c = path.join(directory, `${e}.json`);
|
|
33
|
+
if (envConfit.get(`env:${e}`) && (await pathExists(c))) {
|
|
34
|
+
configFactory.addDefault(c);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
await ENVIRONMENTS.reduce(
|
|
41
|
+
(runningPromise, environment) => runningPromise.then((prev) => prev || addIfEnv(environment)),
|
|
42
|
+
Promise.resolve(false),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const baseConfig = path.join(directory, 'config.json');
|
|
46
|
+
if (await pathExists(baseConfig)) {
|
|
47
|
+
configFactory.addDefault(baseConfig);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ServiceConfigurationSpec {
|
|
52
|
+
// Used for "sourcerequire" and other source-relative paths and for the package name
|
|
53
|
+
rootDirectory: string;
|
|
54
|
+
// The LAST configuration is the most "specific" - if a configuration value
|
|
55
|
+
// exists in all directories, the last one wins
|
|
56
|
+
configurationDirectories: string[];
|
|
57
|
+
name: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function loadConfiguration({
|
|
61
|
+
name,
|
|
62
|
+
configurationDirectories: dirs,
|
|
63
|
+
rootDirectory,
|
|
64
|
+
}: ServiceConfigurationSpec): Promise<ConfigStore> {
|
|
65
|
+
const defaultProtocols = shortstops({ name }, rootDirectory);
|
|
66
|
+
const specificConfig = dirs[dirs.length - 1];
|
|
67
|
+
|
|
68
|
+
// This confit version just gets us environment info
|
|
69
|
+
const envConfit: ConfigStore = await new Promise((accept, reject) => {
|
|
70
|
+
confit(specificConfig).create((err, config) => (err ? reject(err) : accept(config)));
|
|
71
|
+
});
|
|
72
|
+
const configFactory = confit({
|
|
73
|
+
basedir: specificConfig,
|
|
74
|
+
protocols: defaultProtocols,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Note that in confit, when using addDefault,
|
|
79
|
+
* the FIRST addDefault takes precendence over the next (and so on), so
|
|
80
|
+
* if you override this method, you should register your defaults first.
|
|
81
|
+
*/
|
|
82
|
+
const defaultOrder = dirs.slice(0, dirs.length - 1).reverse();
|
|
83
|
+
defaultOrder.push(path.join(__dirname, '../..', 'config'));
|
|
84
|
+
await defaultOrder.reduce(
|
|
85
|
+
(promise, dir) => promise.then(() => addDefaultConfiguration(configFactory, dir, envConfit)),
|
|
86
|
+
Promise.resolve(),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const loaded: ConfigStore = await new Promise((accept, reject) => {
|
|
90
|
+
configFactory.create((err, config) => (err ? reject(err) : accept(config)));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// TODO init other stuff based on config here, such as key management or
|
|
94
|
+
// other cloud-aware shortstop handlers
|
|
95
|
+
|
|
96
|
+
return loaded;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function insertConfigurationBefore(
|
|
100
|
+
configDirs: string[] | undefined,
|
|
101
|
+
insert: string,
|
|
102
|
+
before: string,
|
|
103
|
+
) {
|
|
104
|
+
const copy = [...(configDirs || [])];
|
|
105
|
+
const index = copy.indexOf(before);
|
|
106
|
+
if (index === -1) {
|
|
107
|
+
copy.push(insert, before);
|
|
108
|
+
} else {
|
|
109
|
+
copy.splice(index, 0, insert);
|
|
110
|
+
}
|
|
111
|
+
return copy;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export * from './schema';
|
|
115
|
+
export * from './types';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Level } from 'pino';
|
|
2
|
+
|
|
3
|
+
export interface ServiceConfiguration {
|
|
4
|
+
protocol?: string;
|
|
5
|
+
port?: number;
|
|
6
|
+
host?: string;
|
|
7
|
+
basePath?: string;
|
|
8
|
+
proxy?: string | false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ConfigurationItemEnabled {
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ConfigurationSchema extends Record<string, unknown> {
|
|
16
|
+
trustProxy?: string[];
|
|
17
|
+
logging?: {
|
|
18
|
+
level?: Level;
|
|
19
|
+
logHttpRequests?: boolean;
|
|
20
|
+
logRequestBody?: boolean;
|
|
21
|
+
logResponseBody?: boolean;
|
|
22
|
+
},
|
|
23
|
+
routing?: {
|
|
24
|
+
openapi?: boolean;
|
|
25
|
+
// Relative to the *root directory* of the app
|
|
26
|
+
routes?: string;
|
|
27
|
+
// Whether to add middleware that "freezes" the query string
|
|
28
|
+
// rather than preserving the new Express@5 behavior of reparsing
|
|
29
|
+
// every time (which causes problems for OpenAPI validation)
|
|
30
|
+
freezeQuery?: boolean;
|
|
31
|
+
// Whether to compute etag headers. http://expressjs.com/en/api.html#etag.options.table
|
|
32
|
+
etag?: boolean;
|
|
33
|
+
cookieParser?: boolean;
|
|
34
|
+
bodyParsers?: {
|
|
35
|
+
json?: boolean;
|
|
36
|
+
form?: boolean;
|
|
37
|
+
},
|
|
38
|
+
// Set static.enabled to true to enable static assets to be served
|
|
39
|
+
static?: ConfigurationItemEnabled & {
|
|
40
|
+
// The path relative to the root directory of the app
|
|
41
|
+
path?: string;
|
|
42
|
+
// The path on which to mount the static assets (defaults to /)
|
|
43
|
+
mountPath?: string;
|
|
44
|
+
},
|
|
45
|
+
finalHandlers: {
|
|
46
|
+
// Whether to create and return errors for unhandled routes
|
|
47
|
+
notFound?: boolean;
|
|
48
|
+
// Whether to handle errors and return them to clients
|
|
49
|
+
// (currently means we will return JSON errors)
|
|
50
|
+
errors?: ConfigurationItemEnabled & {
|
|
51
|
+
render?: boolean;
|
|
52
|
+
// Check to see if we got an error from an upstream
|
|
53
|
+
// service that has code/domain/message, and if so return
|
|
54
|
+
// that as is. Otherwise we will sanitize it to avoid leaking
|
|
55
|
+
// information.
|
|
56
|
+
unnest: boolean;
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
server?: {
|
|
61
|
+
internalPort?: number,
|
|
62
|
+
port?: number,
|
|
63
|
+
metrics: ConfigurationItemEnabled,
|
|
64
|
+
},
|
|
65
|
+
connections: Record<string, ServiceConfiguration>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import shortstop from 'shortstop-handlers';
|
|
5
|
+
import shortstopYaml from 'shortstop-yaml';
|
|
6
|
+
import shortstopDns from 'shortstop-dns';
|
|
7
|
+
import { ProtocolFn } from '@gasbuddy/confit';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default shortstop handlers for GasBuddy service configuration
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A require: shortstop that will dig and find a named function
|
|
15
|
+
* with a url-like hash pattern
|
|
16
|
+
*/
|
|
17
|
+
function betterRequire(basepath: string) {
|
|
18
|
+
const baseRequire = shortstop.require(basepath);
|
|
19
|
+
return function hashRequire(v: string) {
|
|
20
|
+
const [moduleName, func] = v.split('#');
|
|
21
|
+
const module = baseRequire(moduleName);
|
|
22
|
+
if (func) {
|
|
23
|
+
if (module[func]) {
|
|
24
|
+
return module[func];
|
|
25
|
+
}
|
|
26
|
+
return baseRequire(v);
|
|
27
|
+
}
|
|
28
|
+
return module;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Our convention is that service names end with:
|
|
34
|
+
* -serv - a back end service not callable by the outside world and where no authorization occurs
|
|
35
|
+
* -api - a non-UI front end service that exposes swagger and sometimes non-swagger APIs
|
|
36
|
+
* -web - a UI front end service
|
|
37
|
+
* -worker - a scheduled job or queue processor
|
|
38
|
+
*
|
|
39
|
+
* This shortstop will take a CSV of service types and tell you if this service is
|
|
40
|
+
* of that type, or if the first character after serviceType: is an exclamation point,
|
|
41
|
+
* whether it's NOT of any of the specified types
|
|
42
|
+
*/
|
|
43
|
+
function serviceTypeFactory(name: string) {
|
|
44
|
+
const type = name.split('-').pop();
|
|
45
|
+
|
|
46
|
+
return function serviceType(v: string) {
|
|
47
|
+
let checkValue = v;
|
|
48
|
+
let matchIsGood = true;
|
|
49
|
+
if (checkValue[0] === '!') {
|
|
50
|
+
matchIsGood = false;
|
|
51
|
+
checkValue = checkValue.substring(1);
|
|
52
|
+
}
|
|
53
|
+
const values = checkValue.split(',');
|
|
54
|
+
// Welp, there's no XOR so here we are.
|
|
55
|
+
return type && values.includes(type) ? matchIsGood : !matchIsGood;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const osMethods = {
|
|
60
|
+
hostname: os.hostname,
|
|
61
|
+
platform: os.platform,
|
|
62
|
+
type: os.type,
|
|
63
|
+
version: os.version,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function shortstops(
|
|
67
|
+
service: { name: string; },
|
|
68
|
+
sourcedir: string,
|
|
69
|
+
) {
|
|
70
|
+
/**
|
|
71
|
+
* Since we use transpiled sources a lot,
|
|
72
|
+
* basedir and sourcedir are meaningfully different reference points.
|
|
73
|
+
*/
|
|
74
|
+
const basedir = path.join(sourcedir, '..');
|
|
75
|
+
|
|
76
|
+
const env = shortstop.env();
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
env,
|
|
80
|
+
// A version of env that can default to false
|
|
81
|
+
env_switch(v: string) {
|
|
82
|
+
if (v && v[0] === '!') {
|
|
83
|
+
const bval = env(`${v.substring(1)}|b`);
|
|
84
|
+
return !bval;
|
|
85
|
+
}
|
|
86
|
+
return !!env(v);
|
|
87
|
+
},
|
|
88
|
+
base64: shortstop.base64(),
|
|
89
|
+
regex(v: string) {
|
|
90
|
+
const [, pattern, flags] = v.match(/^\/(.*)\/([a-z]*)/) || [];
|
|
91
|
+
return new RegExp(pattern, flags);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// handle source and base directory intelligently
|
|
95
|
+
path: shortstop.path(basedir),
|
|
96
|
+
sourcepath: shortstop.path(sourcedir),
|
|
97
|
+
file: shortstop.file(basedir),
|
|
98
|
+
sourcefile: shortstop.file(sourcedir),
|
|
99
|
+
require: betterRequire(basedir),
|
|
100
|
+
sourcerequire: betterRequire(sourcedir),
|
|
101
|
+
|
|
102
|
+
// Sometimes yaml is more pleasant for configuration
|
|
103
|
+
yaml: shortstopYaml(basedir) as ProtocolFn<ReturnType<typeof JSON.parse>>,
|
|
104
|
+
|
|
105
|
+
// Switch on service type
|
|
106
|
+
servicetype: serviceTypeFactory(service.name),
|
|
107
|
+
servicename: (v: string) => v.replace(/\$\{name\}/g, service.name),
|
|
108
|
+
|
|
109
|
+
os(p: keyof typeof osMethods) {
|
|
110
|
+
return osMethods[p]();
|
|
111
|
+
},
|
|
112
|
+
dns: shortstopDns() as ProtocolFn<string>,
|
|
113
|
+
// No-op in case you have values that start with a shortstop handler name (and colon)
|
|
114
|
+
literal(v: string) {
|
|
115
|
+
return v;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import net from 'net';
|
|
2
|
+
|
|
3
|
+
// Inspired by https://github.com/kessler/find-port/blob/master/lib/findPort.js
|
|
4
|
+
async function isAvailable(port: number) {
|
|
5
|
+
return new Promise((accept, reject) => {
|
|
6
|
+
const server = net.createServer().listen(port);
|
|
7
|
+
|
|
8
|
+
const timeoutRef = setTimeout(() => {
|
|
9
|
+
accept(false);
|
|
10
|
+
}, 2000);
|
|
11
|
+
|
|
12
|
+
timeoutRef.unref();
|
|
13
|
+
|
|
14
|
+
server.once('listening', () => {
|
|
15
|
+
clearTimeout(timeoutRef);
|
|
16
|
+
server.close();
|
|
17
|
+
accept(true);
|
|
18
|
+
});
|
|
19
|
+
server.once('error', (err) => {
|
|
20
|
+
clearTimeout(timeoutRef);
|
|
21
|
+
|
|
22
|
+
if ((err as { code?: string; }).code === 'EADDRINUSE') {
|
|
23
|
+
accept(false);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
reject(err);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function findPort(start: number) {
|
|
33
|
+
for (let p = start; p < start + 1000; p += 1) {
|
|
34
|
+
// eslint-disable-next-line no-await-in-loop
|
|
35
|
+
if (await isAvailable(p)) {
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import repl from 'repl';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { ServiceExpress } from '../types';
|
|
5
|
+
|
|
6
|
+
export function serviceRepl(app: ServiceExpress, onExit: () => void) {
|
|
7
|
+
const rl = repl.start({
|
|
8
|
+
prompt: '> ',
|
|
9
|
+
});
|
|
10
|
+
Object.assign(rl.context, app.locals, {
|
|
11
|
+
app,
|
|
12
|
+
dump(o: unknown) {
|
|
13
|
+
// eslint-disable-next-line no-console
|
|
14
|
+
console.log(JSON.stringify(o, null, '\t'));
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
rl.setupHistory(path.resolve('.node_repl_history'), (err) => {
|
|
18
|
+
if (err) {
|
|
19
|
+
// eslint-disable-next-line no-console
|
|
20
|
+
console.error('History setup failed', err);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
rl.on('exit', onExit);
|
|
24
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function getNodeEnv() {
|
|
2
|
+
switch (process.env.APP_ENV || process.env.NODE_ENV) {
|
|
3
|
+
case 'production':
|
|
4
|
+
case 'staging':
|
|
5
|
+
case 'test':
|
|
6
|
+
return process.env.APP_ENV || process.env.NODE_ENV;
|
|
7
|
+
default:
|
|
8
|
+
return 'development';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isDev() {
|
|
13
|
+
return getNodeEnv() === 'development';
|
|
14
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ServiceLike, ServiceLocals } from './types';
|
|
2
|
+
|
|
3
|
+
export interface ServiceErrorSpec {
|
|
4
|
+
status?: number;
|
|
5
|
+
code?: string;
|
|
6
|
+
domain?: string;
|
|
7
|
+
display_message?: string;
|
|
8
|
+
log_stack?: boolean;
|
|
9
|
+
expected_error?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An error that gives more structured information to callers. Throw inside a handler as
|
|
14
|
+
*
|
|
15
|
+
* throw new Error(req, 'Something broke', { code: 'SomethingBroke', status: 400 });
|
|
16
|
+
*
|
|
17
|
+
* You can also include a display_message which is intended to be viewed by the end user
|
|
18
|
+
*/
|
|
19
|
+
export class ServiceError extends Error {
|
|
20
|
+
public status: number | undefined;
|
|
21
|
+
|
|
22
|
+
public code?: string;
|
|
23
|
+
|
|
24
|
+
public domain: string;
|
|
25
|
+
|
|
26
|
+
public display_message?: string;
|
|
27
|
+
|
|
28
|
+
public log_stack?: boolean;
|
|
29
|
+
|
|
30
|
+
// If true, this shouldn't be logged as an error, but as an info log.
|
|
31
|
+
// This is common when the error needs to go to the client, but should not
|
|
32
|
+
// take up the valuable mental space of an error log.
|
|
33
|
+
public expected_error?: boolean;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
app: ServiceLike<ServiceLocals>,
|
|
37
|
+
message: string,
|
|
38
|
+
spec?: ServiceErrorSpec,
|
|
39
|
+
) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.domain = app.locals.name;
|
|
42
|
+
Object.assign(this, spec);
|
|
43
|
+
}
|
|
44
|
+
}
|