@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.
Files changed (121) hide show
  1. package/.eslintignore +7 -0
  2. package/.eslintrc.js +14 -0
  3. package/.github/workflows/codeql-analysis.yml +74 -0
  4. package/.github/workflows/nodejs.yml +23 -0
  5. package/.github/workflows/npmpublish.yml +35 -0
  6. package/.husky/pre-commit +6 -0
  7. package/.prettierrc.js +14 -0
  8. package/@types/config.d.ts +56 -0
  9. package/CHANGELOG.md +5 -0
  10. package/LICENSE +21 -0
  11. package/README.md +28 -0
  12. package/SECURITY.md +12 -0
  13. package/__tests__/config.test.ts +31 -0
  14. package/__tests__/fake-serv/api/fake-serv.yaml +48 -0
  15. package/__tests__/fake-serv/config/config.json +15 -0
  16. package/__tests__/fake-serv/src/handlers/hello.ts +10 -0
  17. package/__tests__/fake-serv/src/index.ts +29 -0
  18. package/__tests__/fake-serv/src/routes/error.ts +13 -0
  19. package/__tests__/fake-serv/src/routes/index.ts +22 -0
  20. package/__tests__/fake-serv/src/routes/other/world.ts +7 -0
  21. package/__tests__/fake-serv.test.ts +74 -0
  22. package/build/bin/start-service.d.ts +2 -0
  23. package/build/bin/start-service.js +31 -0
  24. package/build/bin/start-service.js.map +1 -0
  25. package/build/bootstrap.d.ts +16 -0
  26. package/build/bootstrap.js +90 -0
  27. package/build/bootstrap.js.map +1 -0
  28. package/build/config/index.d.ts +10 -0
  29. package/build/config/index.js +98 -0
  30. package/build/config/index.js.map +1 -0
  31. package/build/config/schema.d.ts +48 -0
  32. package/build/config/schema.js +3 -0
  33. package/build/config/schema.js.map +1 -0
  34. package/build/config/shortstops.d.ts +31 -0
  35. package/build/config/shortstops.js +109 -0
  36. package/build/config/shortstops.js.map +1 -0
  37. package/build/config/types.d.ts +3 -0
  38. package/build/config/types.js +3 -0
  39. package/build/config/types.js.map +1 -0
  40. package/build/development/port-finder.d.ts +1 -0
  41. package/build/development/port-finder.js +41 -0
  42. package/build/development/port-finder.js.map +1 -0
  43. package/build/development/repl.d.ts +2 -0
  44. package/build/development/repl.js +29 -0
  45. package/build/development/repl.js.map +1 -0
  46. package/build/env.d.ts +2 -0
  47. package/build/env.js +19 -0
  48. package/build/env.js.map +1 -0
  49. package/build/error.d.ts +25 -0
  50. package/build/error.js +28 -0
  51. package/build/error.js.map +1 -0
  52. package/build/express-app/app.d.ts +6 -0
  53. package/build/express-app/app.js +327 -0
  54. package/build/express-app/app.js.map +1 -0
  55. package/build/express-app/index.d.ts +2 -0
  56. package/build/express-app/index.js +19 -0
  57. package/build/express-app/index.js.map +1 -0
  58. package/build/express-app/internal-server.d.ts +3 -0
  59. package/build/express-app/internal-server.js +34 -0
  60. package/build/express-app/internal-server.js.map +1 -0
  61. package/build/express-app/route-loader.d.ts +2 -0
  62. package/build/express-app/route-loader.js +46 -0
  63. package/build/express-app/route-loader.js.map +1 -0
  64. package/build/express-app/types.d.ts +14 -0
  65. package/build/express-app/types.js +3 -0
  66. package/build/express-app/types.js.map +1 -0
  67. package/build/index.d.ts +8 -0
  68. package/build/index.js +25 -0
  69. package/build/index.js.map +1 -0
  70. package/build/openapi.d.ts +5 -0
  71. package/build/openapi.js +78 -0
  72. package/build/openapi.js.map +1 -0
  73. package/build/service-calls/index.d.ts +16 -0
  74. package/build/service-calls/index.js +85 -0
  75. package/build/service-calls/index.js.map +1 -0
  76. package/build/telemetry/fetchInstrumentation.d.ts +50 -0
  77. package/build/telemetry/fetchInstrumentation.js +144 -0
  78. package/build/telemetry/fetchInstrumentation.js.map +1 -0
  79. package/build/telemetry/index.d.ts +6 -0
  80. package/build/telemetry/index.js +80 -0
  81. package/build/telemetry/index.js.map +1 -0
  82. package/build/telemetry/instrumentations.d.ts +29 -0
  83. package/build/telemetry/instrumentations.js +47 -0
  84. package/build/telemetry/instrumentations.js.map +1 -0
  85. package/build/telemetry/requestLogger.d.ts +6 -0
  86. package/build/telemetry/requestLogger.js +144 -0
  87. package/build/telemetry/requestLogger.js.map +1 -0
  88. package/build/tsconfig.build.tsbuildinfo +1 -0
  89. package/build/types.d.ts +77 -0
  90. package/build/types.js +3 -0
  91. package/build/types.js.map +1 -0
  92. package/config/config.json +31 -0
  93. package/config/development.json +11 -0
  94. package/config/test.json +5 -0
  95. package/jest.config.js +14 -0
  96. package/package.json +111 -0
  97. package/src/bin/start-service.ts +28 -0
  98. package/src/bootstrap.ts +112 -0
  99. package/src/config/index.ts +115 -0
  100. package/src/config/schema.ts +66 -0
  101. package/src/config/shortstops.ts +118 -0
  102. package/src/config/types.ts +5 -0
  103. package/src/development/port-finder.ts +40 -0
  104. package/src/development/repl.ts +24 -0
  105. package/src/env.ts +14 -0
  106. package/src/error.ts +44 -0
  107. package/src/express-app/app.ts +399 -0
  108. package/src/express-app/index.ts +2 -0
  109. package/src/express-app/internal-server.ts +31 -0
  110. package/src/express-app/route-loader.ts +48 -0
  111. package/src/express-app/types.ts +31 -0
  112. package/src/index.ts +8 -0
  113. package/src/openapi.ts +67 -0
  114. package/src/service-calls/index.ts +129 -0
  115. package/src/telemetry/fetchInstrumentation.ts +209 -0
  116. package/src/telemetry/index.ts +69 -0
  117. package/src/telemetry/instrumentations.ts +54 -0
  118. package/src/telemetry/requestLogger.ts +193 -0
  119. package/src/types.ts +139 -0
  120. package/tsconfig.build.json +10 -0
  121. 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
+ });
@@ -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,5 @@
1
+ export interface ConfigStore {
2
+ // Confit supports more things (set, use), but that's not how we
3
+ // intend it to be used.
4
+ get<T>(name: string): T | undefined;
5
+ }
@@ -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
+ }