@terreno/api 0.0.1

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 (119) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +170 -0
  3. package/biome.jsonc +22 -0
  4. package/bunfig.toml +4 -0
  5. package/dist/api.d.ts +227 -0
  6. package/dist/api.js +1024 -0
  7. package/dist/api.test.d.ts +1 -0
  8. package/dist/api.test.js +2143 -0
  9. package/dist/auth.d.ts +50 -0
  10. package/dist/auth.js +512 -0
  11. package/dist/auth.test.d.ts +1 -0
  12. package/dist/auth.test.js +778 -0
  13. package/dist/errors.d.ts +75 -0
  14. package/dist/errors.js +216 -0
  15. package/dist/example.d.ts +1 -0
  16. package/dist/example.js +118 -0
  17. package/dist/expressServer.d.ts +35 -0
  18. package/dist/expressServer.js +436 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.js +30 -0
  21. package/dist/logger.d.ts +23 -0
  22. package/dist/logger.js +249 -0
  23. package/dist/middleware.d.ts +10 -0
  24. package/dist/middleware.js +52 -0
  25. package/dist/notifiers/googleChatNotifier.d.ts +5 -0
  26. package/dist/notifiers/googleChatNotifier.js +130 -0
  27. package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
  28. package/dist/notifiers/googleChatNotifier.test.js +260 -0
  29. package/dist/notifiers/index.d.ts +3 -0
  30. package/dist/notifiers/index.js +19 -0
  31. package/dist/notifiers/slackNotifier.d.ts +5 -0
  32. package/dist/notifiers/slackNotifier.js +130 -0
  33. package/dist/notifiers/slackNotifier.test.d.ts +1 -0
  34. package/dist/notifiers/slackNotifier.test.js +259 -0
  35. package/dist/notifiers/zoomNotifier.d.ts +34 -0
  36. package/dist/notifiers/zoomNotifier.js +181 -0
  37. package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
  38. package/dist/notifiers/zoomNotifier.test.js +370 -0
  39. package/dist/openApi.d.ts +60 -0
  40. package/dist/openApi.js +441 -0
  41. package/dist/openApi.test.d.ts +1 -0
  42. package/dist/openApi.test.js +445 -0
  43. package/dist/openApiBuilder.d.ts +419 -0
  44. package/dist/openApiBuilder.js +424 -0
  45. package/dist/openApiBuilder.test.d.ts +1 -0
  46. package/dist/openApiBuilder.test.js +509 -0
  47. package/dist/openApiEtag.d.ts +7 -0
  48. package/dist/openApiEtag.js +38 -0
  49. package/dist/permissions.d.ts +26 -0
  50. package/dist/permissions.js +331 -0
  51. package/dist/permissions.test.d.ts +1 -0
  52. package/dist/permissions.test.js +413 -0
  53. package/dist/plugins.d.ts +67 -0
  54. package/dist/plugins.js +315 -0
  55. package/dist/plugins.test.d.ts +1 -0
  56. package/dist/plugins.test.js +639 -0
  57. package/dist/populate.d.ts +14 -0
  58. package/dist/populate.js +315 -0
  59. package/dist/populate.test.d.ts +1 -0
  60. package/dist/populate.test.js +133 -0
  61. package/dist/response.d.ts +0 -0
  62. package/dist/response.js +1 -0
  63. package/dist/tests/bunSetup.d.ts +1 -0
  64. package/dist/tests/bunSetup.js +297 -0
  65. package/dist/tests/index.d.ts +1 -0
  66. package/dist/tests/index.js +17 -0
  67. package/dist/tests.d.ts +99 -0
  68. package/dist/tests.js +273 -0
  69. package/dist/transformers.d.ts +25 -0
  70. package/dist/transformers.js +217 -0
  71. package/dist/transformers.test.d.ts +1 -0
  72. package/dist/transformers.test.js +370 -0
  73. package/dist/utils.d.ts +11 -0
  74. package/dist/utils.js +143 -0
  75. package/dist/utils.test.d.ts +1 -0
  76. package/dist/utils.test.js +14 -0
  77. package/index.ts +1 -0
  78. package/package.json +88 -0
  79. package/src/__snapshots__/openApi.test.ts.snap +4814 -0
  80. package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
  81. package/src/api.test.ts +1661 -0
  82. package/src/api.ts +1036 -0
  83. package/src/auth.test.ts +550 -0
  84. package/src/auth.ts +408 -0
  85. package/src/errors.ts +225 -0
  86. package/src/example.ts +99 -0
  87. package/src/express.d.ts +5 -0
  88. package/src/expressServer.ts +387 -0
  89. package/src/index.ts +14 -0
  90. package/src/logger.ts +190 -0
  91. package/src/middleware.ts +18 -0
  92. package/src/notifiers/googleChatNotifier.test.ts +114 -0
  93. package/src/notifiers/googleChatNotifier.ts +47 -0
  94. package/src/notifiers/index.ts +3 -0
  95. package/src/notifiers/slackNotifier.test.ts +113 -0
  96. package/src/notifiers/slackNotifier.ts +55 -0
  97. package/src/notifiers/zoomNotifier.test.ts +207 -0
  98. package/src/notifiers/zoomNotifier.ts +111 -0
  99. package/src/openApi.test.ts +331 -0
  100. package/src/openApi.ts +494 -0
  101. package/src/openApiBuilder.test.ts +442 -0
  102. package/src/openApiBuilder.ts +636 -0
  103. package/src/openApiEtag.ts +40 -0
  104. package/src/permissions.test.ts +219 -0
  105. package/src/permissions.ts +228 -0
  106. package/src/plugins.test.ts +390 -0
  107. package/src/plugins.ts +289 -0
  108. package/src/populate.test.ts +65 -0
  109. package/src/populate.ts +258 -0
  110. package/src/response.ts +0 -0
  111. package/src/tests/bunSetup.ts +234 -0
  112. package/src/tests/index.ts +1 -0
  113. package/src/tests.ts +218 -0
  114. package/src/transformers.test.ts +202 -0
  115. package/src/transformers.ts +170 -0
  116. package/src/utils.test.ts +14 -0
  117. package/src/utils.ts +47 -0
  118. package/tsconfig.json +60 -0
  119. package/types.d.ts +17 -0
package/src/example.ts ADDED
@@ -0,0 +1,99 @@
1
+ import express from "express";
2
+ import mongoose, {model, Schema} from "mongoose";
3
+ import passportLocalMongoose from "passport-local-mongoose";
4
+
5
+ import {modelRouter, type modelRouterOptions} from "./api";
6
+ import {addAuthRoutes, setupAuth} from "./auth";
7
+ import {setupServer} from "./expressServer";
8
+ import {logger} from "./logger";
9
+ import {Permissions} from "./permissions";
10
+ import {baseUserPlugin, createdUpdatedPlugin} from "./plugins";
11
+
12
+ mongoose
13
+ .connect("mongodb://localhost:27017/example")
14
+ .then(() => {
15
+ logger.debug("Connected to mongo");
16
+ })
17
+ .catch((err) => {
18
+ logger.error(`Error connecting to mongo ${err}`);
19
+ });
20
+
21
+ interface User {
22
+ admin: boolean;
23
+ username: string;
24
+ }
25
+
26
+ interface Food {
27
+ name: string;
28
+ calories: number;
29
+ created: Date;
30
+ ownerId: mongoose.Types.ObjectId | User;
31
+ hidden?: boolean;
32
+ }
33
+
34
+ const userSchema = new Schema<User>({
35
+ admin: {default: false, type: Boolean},
36
+ username: String,
37
+ });
38
+
39
+ userSchema.plugin(passportLocalMongoose as any, {usernameField: "email"});
40
+ userSchema.plugin(createdUpdatedPlugin);
41
+ userSchema.plugin(baseUserPlugin);
42
+ const UserModel = model<User>("User", userSchema);
43
+
44
+ const schema = new Schema<Food>({
45
+ calories: Number,
46
+ created: Date,
47
+ hidden: {default: false, type: Boolean},
48
+ name: String,
49
+ ownerId: {ref: "User", type: "ObjectId"},
50
+ });
51
+
52
+ const FoodModel = model<Food>("Food", schema);
53
+
54
+ function getBaseServer() {
55
+ const app = express();
56
+
57
+ app.all("/*", (req, res, next) => {
58
+ res.header("Access-Control-Allow-Origin", "*");
59
+ res.header("Access-Control-Allow-Headers", "*");
60
+ // intercepts OPTIONS method
61
+ if (req.method === "OPTIONS") {
62
+ res.send(200);
63
+ } else {
64
+ next();
65
+ }
66
+ });
67
+ app.use(express.json());
68
+ setupAuth(app, UserModel as any);
69
+ addAuthRoutes(app, UserModel as any);
70
+
71
+ function addRoutes(router: express.Router, options?: Partial<modelRouterOptions<any>>): void {
72
+ router.use(
73
+ "/food",
74
+ modelRouter(FoodModel, {
75
+ ...options,
76
+ openApiOverwrite: {
77
+ get: {responses: {200: {description: "Get all the food"}}},
78
+ },
79
+ permissions: {
80
+ create: [Permissions.IsAuthenticated],
81
+ delete: [Permissions.IsAdmin],
82
+ list: [Permissions.IsAny],
83
+ read: [Permissions.IsAny],
84
+ update: [Permissions.IsOwner],
85
+ },
86
+ queryFields: ["name", "calories", "created", "ownerId", "hidden"],
87
+ })
88
+ );
89
+ }
90
+
91
+ return setupServer({
92
+ addRoutes,
93
+ loggingOptions: {
94
+ level: "debug",
95
+ },
96
+ userModel: UserModel as any,
97
+ });
98
+ }
99
+ getBaseServer();
@@ -0,0 +1,5 @@
1
+ declare namespace Express {
2
+ export interface Request {
3
+ user?: {_id: string | ObjectId; id: string; admin: boolean};
4
+ }
5
+ }
@@ -0,0 +1,387 @@
1
+ import * as Sentry from "@sentry/node";
2
+ import openapi from "@wesleytodd/openapi";
3
+ import cors from "cors";
4
+ import cron from "cron";
5
+ import express, {type Router} from "express";
6
+ import type jwt from "jsonwebtoken";
7
+ import cloneDeep from "lodash/cloneDeep";
8
+ import onFinished from "on-finished";
9
+ import passport from "passport";
10
+ import qs from "qs";
11
+
12
+ import type {modelRouterOptions} from "./api";
13
+ import {addAuthRoutes, addMeRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
14
+ import {apiErrorMiddleware, apiUnauthorizedMiddleware} from "./errors";
15
+ import {type LoggingOptions, logger, setupLogging} from "./logger";
16
+ import {sendToSlack} from "./notifiers";
17
+ import {openApiEtagMiddleware} from "./openApiEtag";
18
+
19
+ const SLOW_READ_MAX = 200;
20
+ const SLOW_WRITE_MAX = 500;
21
+ const IS_JEST = process.env.JEST_WORKER_ID !== undefined;
22
+
23
+ export function setupEnvironment(): void {
24
+ if (!process.env.TOKEN_ISSUER) {
25
+ throw new Error("TOKEN_ISSUER must be set in env.");
26
+ }
27
+ if (!process.env.TOKEN_SECRET) {
28
+ throw new Error("TOKEN_SECRET must be set.");
29
+ }
30
+ if (!process.env.REFRESH_TOKEN_SECRET) {
31
+ throw new Error("REFRESH_TOKEN_SECRET must be set.");
32
+ }
33
+ if (!process.env.SESSION_SECRET) {
34
+ throw new Error("SESSION_SECRET must be set.");
35
+ }
36
+ if (!process.env.TOKEN_EXPIRES_IN && !IS_JEST) {
37
+ logger.warn("TOKEN_EXPIRES_IN is not set so using default.");
38
+ }
39
+ if (!process.env.REFRESH_TOKEN_EXPIRES_IN && !IS_JEST) {
40
+ logger.warn("REFRESH_TOKEN_EXPIRES_IN not set so using default.");
41
+ }
42
+ }
43
+
44
+ export type AddRoutes = (router: Router, options?: Partial<modelRouterOptions<any>>) => void;
45
+
46
+ const logRequestsFinished = (req: any, res: any, startTime: bigint) => {
47
+ const options = (res.locals.loggingOptions ?? {}) as LoggingOptions;
48
+
49
+ const slowReadMs = options.logSlowRequestsReadMs ?? SLOW_READ_MAX;
50
+ const slowWriteMs = options.logSlowRequestsWriteMs ?? SLOW_WRITE_MAX;
51
+
52
+ const diff = process.hrtime.bigint() - startTime;
53
+ const diffInMs = Number(diff) / 1000000;
54
+ let pathName = "unknown";
55
+ if (req.route && req.routeMount) {
56
+ pathName = `${req.routeMount}${req.route.path}`;
57
+ } else if (req.route) {
58
+ pathName = req.route.path;
59
+ } else if (res.statusCode < 400) {
60
+ logger.warn(`Request without route: ${req.originalUrl}`);
61
+ }
62
+ if (process.env.DISABLE_LOG_ALL_REQUESTS !== "true") {
63
+ logger.debug(`${req.method} -> ${req.originalUrl} ${res.statusCode} ${`${diffInMs}ms`}`);
64
+ }
65
+ if (options.logSlowRequests) {
66
+ if (diffInMs > slowReadMs && req.method === "GET") {
67
+ logger.warn(
68
+ `Slow GET request, ${JSON.stringify({
69
+ pathName,
70
+ requestTime: diffInMs,
71
+ url: req.originalUrl,
72
+ })}`
73
+ );
74
+ } else if (diffInMs > slowWriteMs) {
75
+ logger.warn(
76
+ `Slow write request ${JSON.stringify({
77
+ pathName,
78
+ requestTime: diffInMs,
79
+ url: req.originalUrl,
80
+ })}`
81
+ );
82
+ }
83
+ }
84
+ };
85
+
86
+ export function logRequests(req: any, res: any, next: any) {
87
+ const startTime = process.hrtime.bigint();
88
+
89
+ let userString = "";
90
+ if (req.user) {
91
+ let type = "User";
92
+ if (req.user?.admin) {
93
+ type = "Admin";
94
+ } else if (req.user?.testUser) {
95
+ type = "Test User";
96
+ } else if (req.user?.type) {
97
+ type = req.user?.type;
98
+ }
99
+ userString = ` <${type}:${req.user.id}>`;
100
+ }
101
+
102
+ let body = "";
103
+ if (req.body && Object.keys(req.body).length > 0) {
104
+ const bodyCopy = cloneDeep(req.body);
105
+ if (bodyCopy.password) {
106
+ bodyCopy.password = "<PASSWORD>";
107
+ }
108
+ body = ` Body: ${JSON.stringify(bodyCopy)}`;
109
+ }
110
+
111
+ if (process.env.DISABLE_LOG_ALL_REQUESTS !== "true") {
112
+ logger.debug(`${req.method} <- ${req.url}${userString}${body}`);
113
+ }
114
+ onFinished(res, () => logRequestsFinished(req, res, startTime));
115
+ next();
116
+ }
117
+
118
+ export function createRouter(rootPath: string, addRoutes: AddRoutes, middleware: any[] = []) {
119
+ function routePathMiddleware(req: any, _res: any, next: any) {
120
+ if (!req.routeMount) {
121
+ req.routeMount = [];
122
+ }
123
+ req.routeMount.push(rootPath);
124
+ next();
125
+ }
126
+
127
+ const router = express.Router();
128
+ router.use(routePathMiddleware);
129
+ addRoutes(router);
130
+ return [rootPath, ...middleware, router];
131
+ }
132
+
133
+ export function createRouterWithAuth(
134
+ rootPath: string,
135
+ addRoutes: (router: Router) => void,
136
+ middleware: any[] = []
137
+ ) {
138
+ return createRouter(rootPath, addRoutes, [
139
+ passport.authenticate("firebase-jwt", {session: false}),
140
+ ...middleware,
141
+ ]);
142
+ }
143
+
144
+ export interface AuthOptions {
145
+ generateJWTPayload?: (user: any) => Record<string, any>;
146
+ generateTokenExpiration?: (user: any) => number | jwt.SignOptions["expiresIn"];
147
+ generateRefreshTokenExpiration?: (user: any) => number | jwt.SignOptions["expiresIn"];
148
+ }
149
+
150
+ interface InitializeRoutesOptions {
151
+ corsOrigin?:
152
+ | string
153
+ | boolean
154
+ | RegExp
155
+ | Array<boolean | string | RegExp>
156
+ | ((
157
+ requestOrigin: string | undefined,
158
+ callback: (
159
+ err: Error | null,
160
+ origin?: boolean | string | RegExp | Array<boolean | string | RegExp>
161
+ ) => void
162
+ ) => void);
163
+ addMiddleware?: AddRoutes;
164
+ // The maximum number of array elements to parse in a query string. Defaults to 200.
165
+ arrayLimit?: number;
166
+ // Whether requests should be logged. In production, you may want to disable this if using another
167
+ // logger (e.g. Google Cloud).
168
+ logRequests?: boolean;
169
+ loggingOptions?: LoggingOptions;
170
+ authOptions?: AuthOptions;
171
+ }
172
+
173
+ function initializeRoutes(
174
+ UserModel: UserMongooseModel,
175
+ addRoutes: AddRoutes,
176
+ options: InitializeRoutesOptions = {}
177
+ ) {
178
+ const app = express();
179
+
180
+ // TODO: Log a warning when we hit the array limit.
181
+ app.set("query parser", (str: string) => qs.parse(str, {arrayLimit: options.arrayLimit ?? 200}));
182
+
183
+ app.use(
184
+ cors({
185
+ origin: options.corsOrigin ?? "*",
186
+ })
187
+ );
188
+
189
+ if (options.addMiddleware) {
190
+ options.addMiddleware(app);
191
+ }
192
+
193
+ app.use(express.json());
194
+
195
+ // Add login/signup/refresh_token before the JWT/auth middlewares
196
+ addAuthRoutes(app, UserModel as any, options?.authOptions);
197
+
198
+ setupAuth(app as any, UserModel as any);
199
+
200
+ if (options.logRequests !== false) {
201
+ app.use(logRequests);
202
+ }
203
+
204
+ // Store the logging options on the request so we can access them later.
205
+ app.use((_req, res, next) => {
206
+ res.locals.loggingOptions = options.loggingOptions;
207
+ next();
208
+ });
209
+
210
+ // Add Sentry scopes for session, transaction, and userId if any are set
211
+ app.all("*", (req: any, _res: any, next: any) => {
212
+ const transactionId = req.header("X-Transaction-ID");
213
+ const sessionId = req.header("X-Session-ID");
214
+ if (transactionId) {
215
+ Sentry.getCurrentScope().setTag("transaction_id", transactionId);
216
+ }
217
+ if (sessionId) {
218
+ Sentry.getCurrentScope().setTag("session_id", sessionId);
219
+ }
220
+ if (req.user?._id) {
221
+ Sentry.getCurrentScope().setTag("user", req.user._id);
222
+ }
223
+ next();
224
+ });
225
+
226
+ // Add ETag middleware for OpenAPI JSON endpoint before the openapi middleware
227
+ app.use(openApiEtagMiddleware);
228
+
229
+ const oapi = openapi({
230
+ info: {
231
+ description: "Generated docs from an Express api",
232
+ title: "Express Application",
233
+ version: "1.0.0",
234
+ },
235
+ openapi: "3.0.0",
236
+ });
237
+ app.use(oapi);
238
+
239
+ if (process.env.ENABLE_SWAGGER === "true") {
240
+ app.use("/swagger", oapi.swaggerui());
241
+ }
242
+
243
+ addMeRoutes(app, UserModel as any, options?.authOptions);
244
+ addRoutes(app, {openApi: oapi});
245
+
246
+ Sentry.setupExpressErrorHandler(app);
247
+
248
+ // Catch any thrown APIErrors and return them in an OpenAPI compatible format
249
+ app.use(apiUnauthorizedMiddleware);
250
+ app.use(apiErrorMiddleware);
251
+
252
+ app.use(function onError(err: any, _req: any, res: any, _next: any) {
253
+ logger.error(`Fallthrough error: ${err}${err?.stack ? `\n${err.stack}` : ""}}`);
254
+ Sentry.captureException(err);
255
+ res.statusCode = 500;
256
+ res.end(`${res.sentry}\n`);
257
+ });
258
+
259
+ return app;
260
+ }
261
+
262
+ export interface SetupServerOptions {
263
+ userModel: UserMongooseModel;
264
+ addRoutes: AddRoutes;
265
+ loggingOptions?: LoggingOptions;
266
+ authOptions?: AuthOptions;
267
+ skipListen?: boolean;
268
+ corsOrigin?:
269
+ | string
270
+ | boolean
271
+ | RegExp
272
+ | Array<boolean | string | RegExp>
273
+ | ((
274
+ requestOrigin: string | undefined,
275
+ callback: (
276
+ err: Error | null,
277
+ origin?: boolean | string | RegExp | Array<boolean | string | RegExp>
278
+ ) => void
279
+ ) => void);
280
+ addMiddleware?: AddRoutes;
281
+ ignoreTraces?: string[];
282
+ sentryOptions?: Sentry.NodeOptions;
283
+ }
284
+
285
+ // Sets up the routes and returns a function to launch the API.
286
+ export function setupServer(options: SetupServerOptions) {
287
+ const UserModel = options.userModel;
288
+ const addRoutes = options.addRoutes;
289
+
290
+ setupLogging(options.loggingOptions);
291
+
292
+ let app: express.Application;
293
+ try {
294
+ app = initializeRoutes(UserModel, addRoutes, {
295
+ addMiddleware: options.addMiddleware,
296
+ authOptions: options.authOptions,
297
+ corsOrigin: options.corsOrigin,
298
+ });
299
+ } catch (error: any) {
300
+ logger.error(`Error initializing routes: ${error.stack}`);
301
+ throw error;
302
+ }
303
+
304
+ if (!options.skipListen) {
305
+ const port = process.env.PORT || "9000";
306
+ try {
307
+ app.listen(port, () => {
308
+ logger.info(`Listening on port ${port}`);
309
+ });
310
+ } catch (error) {
311
+ logger.error(`Error trying to start HTTP server: ${error}\n${(error as any).stack}`);
312
+ process.exit(1);
313
+ }
314
+ }
315
+ return app;
316
+ }
317
+
318
+ // Convenience method to execute cronjobs with an always-running server.
319
+ export function cronjob(
320
+ name: string,
321
+ schedule: "hourly" | "minutely" | string,
322
+ callback: () => void
323
+ ) {
324
+ let _cronSchedule = schedule;
325
+ if (schedule === "hourly") {
326
+ _cronSchedule = "0 * * * *";
327
+ } else if (schedule === "minutely") {
328
+ _cronSchedule = "* * * * *";
329
+ }
330
+ logger.info(`Adding cronjob ${name}, running at: ${schedule}`);
331
+ try {
332
+ new cron.CronJob(schedule, callback, null, true, "America/Chicago");
333
+ } catch (error) {
334
+ throw new Error(`Failed to create cronjob: ${error}`);
335
+ }
336
+ }
337
+
338
+ export interface WrapScriptOptions {
339
+ onFinish?: (result?: any) => void | Promise<void>;
340
+ terminateTimeout?: number; // in seconds, defaults to 300. Set to 0 to have no termination timeout.
341
+ slackChannel?: string;
342
+ }
343
+ // Wrap up a script with some helpers, such as catching errors, reporting them to sentry, notifying
344
+ // Slack of runs, etc. Also supports timeouts.
345
+ export async function wrapScript(func: () => Promise<any>, options: WrapScriptOptions = {}) {
346
+ const name = require.main?.filename.split("/").slice(-1)[0].replace(".ts", "");
347
+ logger.info(`Running script ${name}`);
348
+ await sendToSlack(`Running script ${name}`, {
349
+ slackChannel: options.slackChannel,
350
+ });
351
+
352
+ if (options.terminateTimeout !== 0) {
353
+ const warnTime = ((options.terminateTimeout ?? 300) / 2) * 1000;
354
+ const closeTime = (options.terminateTimeout ?? 300) * 1000;
355
+ setTimeout(async () => {
356
+ const msg = `Script ${name} is taking a while, currently ${warnTime / 1000} seconds`;
357
+ await sendToSlack(msg);
358
+ logger.warn(msg);
359
+ }, warnTime);
360
+
361
+ setTimeout(async () => {
362
+ const msg = `Script ${name} took too long, exiting`;
363
+ await sendToSlack(msg);
364
+ logger.error(msg);
365
+ Sentry.captureException(new Error(`Script ${name} took too long, exiting`));
366
+ await Sentry.flush();
367
+ process.exit(2);
368
+ }, closeTime);
369
+ }
370
+
371
+ let result: any;
372
+ try {
373
+ result = await func();
374
+ if (options.onFinish) {
375
+ await options.onFinish(result);
376
+ }
377
+ } catch (error) {
378
+ Sentry.captureException(error);
379
+ logger.error(`Error running script ${name}: ${error}\n${(error as Error).stack}`);
380
+ await sendToSlack(`Error running script ${name}: ${error}\n${(error as Error).stack}`);
381
+ await Sentry.flush();
382
+ process.exit(1);
383
+ }
384
+ await sendToSlack(`Success running script ${name}: ${result}`);
385
+ // Unclear why we have to exit here to prevent the script for continuing to run.
386
+ process.exit(0);
387
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export * from "./api";
2
+ export * from "./auth";
3
+ export * from "./errors";
4
+ export * from "./expressServer";
5
+ export * from "./logger";
6
+ export * from "./middleware";
7
+ export * from "./notifiers";
8
+ export * from "./openApiBuilder";
9
+ export * from "./openApiEtag";
10
+ export * from "./permissions";
11
+ export * from "./plugins";
12
+ export * from "./populate";
13
+ export * from "./transformers";
14
+ export * from "./utils";
package/src/logger.ts ADDED
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import {inspect} from "node:util";
3
+ import * as Sentry from "@sentry/node";
4
+ import winston from "winston";
5
+
6
+ function isPrimitive(val: any) {
7
+ return val === null || (typeof val !== "object" && typeof val !== "function");
8
+ }
9
+
10
+ function formatWithInspect(val: any) {
11
+ const prefix = isPrimitive(val) ? "" : "\n";
12
+ const shouldFormat = typeof val !== "string";
13
+
14
+ return prefix + (shouldFormat ? inspect(val, {colors: true, depth: null}) : val);
15
+ }
16
+
17
+ // Winston doesn't operate like console.log by default, e.g. `logger.error('error',
18
+ // error)` only prints the message and no args. Add handling for all the args,
19
+ // while also supporting splat logging.
20
+ function printf(timestamp = false) {
21
+ return (info: winston.Logform.TransformableInfo) => {
22
+ const msg = formatWithInspect(info.message);
23
+ const splatArgs = (info[Symbol.for("splat") as any] || []) as any[];
24
+ const rest = splatArgs.map((data: any) => formatWithInspect(data)).join(" ");
25
+ if (timestamp) {
26
+ return `${info.timestamp} - ${info.level}: ${msg} ${rest}`;
27
+ }
28
+ return `${info.level}: ${msg} ${rest}`;
29
+ };
30
+ }
31
+
32
+ // Setup a global, default rejection handler.
33
+ winston.add(
34
+ new winston.transports.Console({
35
+ debugStdout: true,
36
+ format: winston.format.combine(
37
+ winston.format.colorize(),
38
+ winston.format.simple(),
39
+ winston.format.printf(printf(false))
40
+ ),
41
+ handleExceptions: true,
42
+ handleRejections: true,
43
+ level: "error",
44
+ })
45
+ );
46
+
47
+ // Setup a default console logger.
48
+ export const winstonLogger = winston.createLogger({
49
+ level: "debug",
50
+ transports: [
51
+ new winston.transports.Console({
52
+ debugStdout: true,
53
+ format: winston.format.combine(
54
+ winston.format.colorize(),
55
+ winston.format.simple(),
56
+ winston.format.printf(printf(false))
57
+ ),
58
+ handleExceptions: true,
59
+ handleRejections: true,
60
+ level: "debug",
61
+ }),
62
+ ],
63
+ });
64
+
65
+ // Helper function to send logs to Sentry if enabled
66
+ function sendToSentry(message: string, level: "debug" | "info" | "warn" | "error") {
67
+ if (process.env.USE_SENTRY_LOGGING === "true" && Sentry.logger) {
68
+ Sentry.logger[level](message);
69
+ }
70
+ }
71
+
72
+ export const logger = {
73
+ // simple way to log a caught exception. e.g. promise().catch(logger.catch)
74
+ catch: (e: unknown) => {
75
+ const errorMsg = `Caught: ${(e as Error)?.message} ${(e as Error)?.stack}`;
76
+ winstonLogger.error(errorMsg);
77
+ if (process.env.USE_SENTRY_LOGGING === "true") {
78
+ if (e instanceof Error) {
79
+ Sentry.captureException(e);
80
+ } else if (Sentry.logger) {
81
+ Sentry.logger.error(errorMsg);
82
+ }
83
+ }
84
+ },
85
+ debug: (msg: string, ...args: unknown[]) => {
86
+ winstonLogger.debug(msg, ...args);
87
+ sendToSentry(msg, "debug");
88
+ },
89
+ error: (msg: string, ...args: unknown[]) => {
90
+ winstonLogger.error(msg, ...args);
91
+ sendToSentry(msg, "error");
92
+ },
93
+ info: (msg: string, ...args: unknown[]) => {
94
+ winstonLogger.info(msg, ...args);
95
+ sendToSentry(msg, "info");
96
+ },
97
+ warn: (msg: string, ...args: unknown[]) => {
98
+ winstonLogger.warn(msg, ...args);
99
+ sendToSentry(msg, "warn");
100
+ },
101
+ };
102
+
103
+ export interface LoggingOptions {
104
+ level?: "debug" | "info" | "warn" | "error";
105
+ transports?: winston.transport[];
106
+ disableFileLogging?: boolean;
107
+ disableConsoleLogging?: boolean;
108
+ disableConsoleColors?: boolean;
109
+ showConsoleTimestamps?: boolean;
110
+ logDirectory?: string;
111
+ logRequests?: boolean;
112
+ // Whether to log when requests are slow.
113
+ logSlowRequests?: boolean;
114
+ // The threshold in ms for logging slow requests. Defaults to 200ms for read requests.
115
+ logSlowRequestsReadMs?: number;
116
+ // The threshold in ms for logging slow requests. Defaults to 500ms for write requests.
117
+ logSlowRequestsWriteMs?: number;
118
+ }
119
+
120
+ export function setupLogging(options?: LoggingOptions) {
121
+ winstonLogger.clear();
122
+ if (!options?.disableConsoleLogging) {
123
+ const formats: any[] = [winston.format.simple()];
124
+ if (!options?.disableConsoleColors) {
125
+ formats.push(winston.format.colorize());
126
+ }
127
+ formats.push(winston.format.printf(printf(options?.showConsoleTimestamps)));
128
+ winstonLogger.add(
129
+ new winston.transports.Console({
130
+ debugStdout: !options?.level || options?.level === "debug",
131
+ format: winston.format.combine(...formats),
132
+ level: options?.level ?? "debug",
133
+ })
134
+ );
135
+ }
136
+ if (!options?.disableFileLogging) {
137
+ const logDirectory = options?.logDirectory ?? "./log";
138
+ if (!fs.existsSync(logDirectory)) {
139
+ fs.mkdirSync(logDirectory, {recursive: true});
140
+ }
141
+
142
+ const FILE_LOG_DEFAULTS = {
143
+ colorize: false,
144
+ compress: true,
145
+ dirname: logDirectory,
146
+ format: winston.format.simple(),
147
+ // 30 days of retention
148
+ maxFiles: 30,
149
+ // 50MB max file size
150
+ maxSize: 1024 * 1024 * 50,
151
+ // Only readable by server user
152
+ options: {mode: 0o600},
153
+ };
154
+
155
+ winstonLogger.add(
156
+ new winston.transports.Stream({
157
+ ...FILE_LOG_DEFAULTS,
158
+ handleExceptions: true,
159
+ level: "error",
160
+ // Use stream so we can open log in append mode rather than overwriting.
161
+ stream: fs.createWriteStream("error.log", {flags: "a"}),
162
+ })
163
+ );
164
+
165
+ winstonLogger.add(
166
+ new winston.transports.Stream({
167
+ ...FILE_LOG_DEFAULTS,
168
+ level: "info",
169
+ // Use stream so we can open log in append mode rather than overwriting.
170
+ stream: fs.createWriteStream("out.log", {flags: "a"}),
171
+ })
172
+ );
173
+ if (!options?.level || options?.level === "debug") {
174
+ winstonLogger.add(
175
+ new winston.transports.Stream({
176
+ ...FILE_LOG_DEFAULTS,
177
+ level: "debug",
178
+ // Use stream so we can open log in append mode rather than overwriting.
179
+ stream: fs.createWriteStream("debug.log", {flags: "a"}),
180
+ })
181
+ );
182
+ }
183
+ }
184
+
185
+ if (options?.transports) {
186
+ for (const transport of options.transports) {
187
+ winstonLogger.add(transport);
188
+ }
189
+ }
190
+ }