@flink-app/flink 2.0.0-alpha.62 → 2.0.0-alpha.63

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @flink-app/flink
2
2
 
3
+ ## 2.0.0-alpha.63
4
+
5
+ ### Patch Changes
6
+
7
+ - 810df2c: Fix FlinkJob afterDelay: "0ms" running in a tight loop instead of once. Zero-delay jobs now run immediately via setImmediate (exactly once) rather than being registered with a 0ms scheduler interval. Also adds specs covering uncaught exception handling for all job scheduling modes.
8
+ - 8dd0752: fix: prevent server crash when handler returns no data with a response schema defined
9
+
10
+ When a PATCH (or any) handler returned `{ status: 204 }` without a `data` field,
11
+ `JSON.stringify(undefined)` returned the JS value `undefined`, causing
12
+ `JSON.parse(undefined)` to throw a `SyntaxError` that crashed the entire server process.
13
+
14
+ New behaviour:
15
+
16
+ - Status 204 + no data → skip validation silently (intentional no-content response)
17
+ - Non-204 status + no data + response schema defined → return 500 bad response with a
18
+ descriptive error message (surfaces the developer mistake without crashing the server)
19
+ - Data present → validate against schema as before
20
+
3
21
  ## 2.0.0-alpha.62
4
22
 
5
23
  ### Minor Changes
@@ -423,7 +423,7 @@ var FlinkApp = /** @class */ (function () {
423
423
  }
424
424
  }
425
425
  this.expressApp[method](routeProps.path, function (req, res) { return __awaiter(_this, void 0, void 0, function () {
426
- var valid, formattedErrors, data, normalizedQuery, _i, _a, _b, key, value, stream, handlerRes, flinkReq_1, err_1, errorResponse, result, valid, formattedErrors;
426
+ var valid, formattedErrors, data, normalizedQuery, _i, _a, _b, key, value, stream, handlerRes, flinkReq_1, err_1, errorResponse, result, detail, valid, formattedErrors;
427
427
  var _this = this;
428
428
  return __generator(this, function (_c) {
429
429
  switch (_c.label) {
@@ -570,18 +570,30 @@ var FlinkApp = /** @class */ (function () {
570
570
  return [2 /*return*/, res.status(204).send()];
571
571
  }
572
572
  if (validateRes_1 && !(0, utils_1.isError)(handlerRes)) {
573
- valid = validateRes_1(JSON.parse(JSON.stringify(handlerRes.data)));
574
- if (!valid) {
575
- formattedErrors = (0, utils_1.formatValidationErrors)(validateRes_1.errors, handlerRes.data);
576
- FlinkLog_1.log.warn("[".concat(req.reqId, "] ").concat(methodAndRoute_1, ": Bad response\n").concat(formattedErrors));
577
- return [2 /*return*/, res.status(500).json({
578
- status: 500,
579
- error: {
580
- id: (0, uuid_1.v4)(),
581
- title: "Bad response",
582
- detail: formattedErrors,
583
- },
584
- })];
573
+ if (handlerRes.data === undefined) {
574
+ if (handlerRes.status !== 204) {
575
+ detail = "Response schema is defined but handler returned no data";
576
+ FlinkLog_1.log.warn("[".concat(req.reqId, "] ").concat(methodAndRoute_1, ": Bad response - ").concat(detail));
577
+ return [2 /*return*/, res.status(500).json({
578
+ status: 500,
579
+ error: { id: (0, uuid_1.v4)(), title: "Bad response", detail: detail },
580
+ })];
581
+ }
582
+ }
583
+ else {
584
+ valid = validateRes_1(JSON.parse(JSON.stringify(handlerRes.data)));
585
+ if (!valid) {
586
+ formattedErrors = (0, utils_1.formatValidationErrors)(validateRes_1.errors, handlerRes.data);
587
+ FlinkLog_1.log.warn("[".concat(req.reqId, "] ").concat(methodAndRoute_1, ": Bad response\n").concat(formattedErrors));
588
+ return [2 /*return*/, res.status(500).json({
589
+ status: 500,
590
+ error: {
591
+ id: (0, uuid_1.v4)(),
592
+ title: "Bad response",
593
+ detail: formattedErrors,
594
+ },
595
+ })];
596
+ }
585
597
  }
586
598
  }
587
599
  res.set(handlerRes.headers);
@@ -901,14 +913,38 @@ var FlinkApp = /** @class */ (function () {
901
913
  this_1.scheduler.addSimpleIntervalJob(job);
902
914
  }
903
915
  else if (jobProps.afterDelay !== undefined) {
904
- var job = new toad_scheduler_1.SimpleIntervalJob({
905
- milliseconds: (0, ms_1.default)(jobProps.afterDelay),
906
- runImmediately: false,
907
- }, task, {
908
- id: jobProps.id,
909
- preventOverrun: jobProps.singleton,
910
- });
911
- this_1.scheduler.addSimpleIntervalJob(job);
916
+ var delayMs = (0, ms_1.default)(jobProps.afterDelay);
917
+ if (delayMs === 0) {
918
+ setImmediate(function () { return __awaiter(_this, void 0, void 0, function () {
919
+ var err_2;
920
+ return __generator(this, function (_a) {
921
+ switch (_a.label) {
922
+ case 0:
923
+ _a.trys.push([0, 2, , 3]);
924
+ return [4 /*yield*/, jobFn({ ctx: this.ctx })];
925
+ case 1:
926
+ _a.sent();
927
+ return [3 /*break*/, 3];
928
+ case 2:
929
+ err_2 = _a.sent();
930
+ FlinkLog_1.log.error("Job ".concat(jobProps.id, " threw unhandled exception ").concat(err_2));
931
+ console.error(err_2);
932
+ return [3 /*break*/, 3];
933
+ case 3: return [2 /*return*/];
934
+ }
935
+ });
936
+ }); });
937
+ }
938
+ else {
939
+ var job = new toad_scheduler_1.SimpleIntervalJob({
940
+ milliseconds: delayMs,
941
+ runImmediately: false,
942
+ }, task, {
943
+ id: jobProps.id,
944
+ preventOverrun: jobProps.singleton,
945
+ });
946
+ this_1.scheduler.addSimpleIntervalJob(job);
947
+ }
912
948
  }
913
949
  else {
914
950
  FlinkLog_1.log.error("Cannot register job ".concat(jobProps.id, " - no cron, interval or once set in ").concat(__file));
@@ -1111,7 +1147,7 @@ var FlinkApp = /** @class */ (function () {
1111
1147
  */
1112
1148
  FlinkApp.prototype.initDb = function () {
1113
1149
  return __awaiter(this, void 0, void 0, function () {
1114
- var client, err_2;
1150
+ var client, err_3;
1115
1151
  return __generator(this, function (_a) {
1116
1152
  switch (_a.label) {
1117
1153
  case 0:
@@ -1127,8 +1163,8 @@ var FlinkApp = /** @class */ (function () {
1127
1163
  this.dbClient = client;
1128
1164
  return [3 /*break*/, 4];
1129
1165
  case 3:
1130
- err_2 = _a.sent();
1131
- FlinkLog_1.log.error("Failed to connect to db: " + err_2);
1166
+ err_3 = _a.sent();
1167
+ FlinkLog_1.log.error("Failed to connect to db: " + err_3);
1132
1168
  process.exit(1);
1133
1169
  return [3 /*break*/, 4];
1134
1170
  case 4:
@@ -1147,7 +1183,7 @@ var FlinkApp = /** @class */ (function () {
1147
1183
  */
1148
1184
  FlinkApp.prototype.initPluginDb = function (plugin) {
1149
1185
  return __awaiter(this, void 0, void 0, function () {
1150
- var client, err_3;
1186
+ var client, err_4;
1151
1187
  return __generator(this, function (_a) {
1152
1188
  switch (_a.label) {
1153
1189
  case 0:
@@ -1174,8 +1210,8 @@ var FlinkApp = /** @class */ (function () {
1174
1210
  client = _a.sent();
1175
1211
  return [2 /*return*/, client.db()];
1176
1212
  case 4:
1177
- err_3 = _a.sent();
1178
- FlinkLog_1.log.error("Failed to connect to db defined in plugin '".concat(plugin.id, "': ") + err_3);
1213
+ err_4 = _a.sent();
1214
+ FlinkLog_1.log.error("Failed to connect to db defined in plugin '".concat(plugin.id, "': ") + err_4);
1179
1215
  return [3 /*break*/, 5];
1180
1216
  case 5: return [2 /*return*/];
1181
1217
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/flink",
3
- "version": "2.0.0-alpha.62",
3
+ "version": "2.0.0-alpha.63",
4
4
  "description": "Typescript only framework for creating REST-like APIs on top of Express and mongodb",
5
5
  "types": "dist/src/index.d.ts",
6
6
  "main": "dist/src/index.js",
@@ -0,0 +1,123 @@
1
+ import { FlinkApp } from "../src/FlinkApp";
2
+ import { FlinkContext } from "../src/FlinkContext";
3
+ import { GetHandler, Handler, HttpMethod } from "../src/FlinkHttpHandler";
4
+
5
+ const request = require("supertest");
6
+
7
+ interface TestContext extends FlinkContext {}
8
+
9
+ const resSchema = {
10
+ type: "object",
11
+ properties: {
12
+ id: { type: "string" },
13
+ },
14
+ required: ["id"],
15
+ };
16
+
17
+ describe("FlinkApp response validation when handler returns no data", () => {
18
+ let app: FlinkApp<TestContext>;
19
+
20
+ afterEach(async () => {
21
+ if (app && app.started) {
22
+ await app.stop();
23
+ }
24
+ });
25
+
26
+ it("should return 500 bad response when handler returns undefined data with a response schema", async () => {
27
+ const handler: GetHandler<TestContext, any> = async () => {
28
+ return { status: 200 } as any; // no data
29
+ };
30
+
31
+ app = new FlinkApp<TestContext>({ name: "test-undefined-data", port: 4200 });
32
+ await app.start();
33
+
34
+ app.addHandler({
35
+ default: handler,
36
+ Route: { method: HttpMethod.get, path: "/test", resSchema },
37
+ });
38
+
39
+ const response = await request(app.expressApp).get("/test");
40
+
41
+ expect(response.status).toBe(500);
42
+ expect(response.body.error.title).toBe("Bad response");
43
+ expect(response.body.error.detail).toContain("no data");
44
+ });
45
+
46
+ it("should NOT return 500 when handler returns status 204 with no data (even if schema is defined)", async () => {
47
+ const handler: Handler<TestContext, any, any> = async () => {
48
+ return { status: 204 } as any;
49
+ };
50
+
51
+ app = new FlinkApp<TestContext>({ name: "test-204-no-data", port: 4201 });
52
+ await app.start();
53
+
54
+ app.addHandler({
55
+ default: handler,
56
+ Route: { method: HttpMethod.patch, path: "/test", resSchema },
57
+ });
58
+
59
+ const response = await request(app.expressApp).patch("/test");
60
+
61
+ expect(response.status).toBe(204);
62
+ });
63
+
64
+ it("should validate normally when handler returns valid data", async () => {
65
+ const handler: GetHandler<TestContext, any> = async () => {
66
+ return { data: { id: "abc-123" } };
67
+ };
68
+
69
+ app = new FlinkApp<TestContext>({ name: "test-valid-data", port: 4202 });
70
+ await app.start();
71
+
72
+ app.addHandler({
73
+ default: handler,
74
+ Route: { method: HttpMethod.get, path: "/test", resSchema },
75
+ });
76
+
77
+ const response = await request(app.expressApp).get("/test");
78
+
79
+ expect(response.status).toBe(200);
80
+ expect(response.body.data.id).toBe("abc-123");
81
+ });
82
+
83
+ it("should return 500 bad response when handler returns invalid data against schema", async () => {
84
+ const handler: GetHandler<TestContext, any> = async () => {
85
+ return { data: { wrongField: 123 } }; // missing required "id"
86
+ };
87
+
88
+ app = new FlinkApp<TestContext>({ name: "test-invalid-data", port: 4203 });
89
+ await app.start();
90
+
91
+ app.addHandler({
92
+ default: handler,
93
+ Route: { method: HttpMethod.get, path: "/test", resSchema },
94
+ });
95
+
96
+ const response = await request(app.expressApp).get("/test");
97
+
98
+ expect(response.status).toBe(500);
99
+ expect(response.body.error.title).toBe("Bad response");
100
+ });
101
+
102
+ it("should not crash the server when handler returns undefined data", async () => {
103
+ const handler: GetHandler<TestContext, any> = async () => {
104
+ return { status: 200 } as any;
105
+ };
106
+
107
+ app = new FlinkApp<TestContext>({ name: "test-no-crash", port: 4204 });
108
+ await app.start();
109
+
110
+ app.addHandler({
111
+ default: handler,
112
+ Route: { method: HttpMethod.get, path: "/test", resSchema },
113
+ });
114
+
115
+ // First request triggers the bad response
116
+ await request(app.expressApp).get("/test");
117
+
118
+ // Server should still be running and able to handle further requests
119
+ const second = await request(app.expressApp).get("/test");
120
+ expect(second.status).toBe(500); // still returns 500, but doesn't crash
121
+ expect(app.started).toBe(true);
122
+ });
123
+ });
@@ -0,0 +1,95 @@
1
+ import { FlinkApp, autoRegisteredJobs } from "../src/FlinkApp";
2
+ import { FlinkContext } from "../src/FlinkContext";
3
+ import { FlinkJobFile } from "../src/FlinkJob";
4
+
5
+ interface TestContext extends FlinkContext {}
6
+
7
+ describe("FlinkJob error handling", () => {
8
+ let app: FlinkApp<TestContext>;
9
+ let consoleErrorSpy: jasmine.Spy;
10
+
11
+ beforeEach(() => {
12
+ consoleErrorSpy = spyOn(console, "error");
13
+ });
14
+
15
+ afterEach(async () => {
16
+ autoRegisteredJobs.length = 0;
17
+ if (app?.started) {
18
+ await app.stop();
19
+ }
20
+ });
21
+
22
+ it("should catch and log errors from afterDelay 0ms jobs without crashing", async () => {
23
+ const job: FlinkJobFile = {
24
+ Job: { id: "failing-job-0ms", afterDelay: "0ms" },
25
+ default: async () => {
26
+ throw new Error("Job error 0ms");
27
+ },
28
+ };
29
+
30
+ autoRegisteredJobs.push(job);
31
+
32
+ app = new FlinkApp<TestContext>({ name: "test-job-errors-0ms", disableHttpServer: true });
33
+ await app.start();
34
+
35
+ await new Promise((resolve) => setTimeout(resolve, 50));
36
+
37
+ expect(consoleErrorSpy).toHaveBeenCalled();
38
+ });
39
+
40
+ it("should catch and log errors from afterDelay jobs without crashing", async () => {
41
+ const job: FlinkJobFile = {
42
+ Job: { id: "failing-job-delay", afterDelay: "10ms" },
43
+ default: async () => {
44
+ throw new Error("Job error with delay");
45
+ },
46
+ };
47
+
48
+ autoRegisteredJobs.push(job);
49
+
50
+ app = new FlinkApp<TestContext>({ name: "test-job-errors-delay", disableHttpServer: true });
51
+ await app.start();
52
+
53
+ await new Promise((resolve) => setTimeout(resolve, 100));
54
+
55
+ expect(consoleErrorSpy).toHaveBeenCalled();
56
+ });
57
+
58
+ it("should catch and log errors from interval jobs without crashing", async () => {
59
+ const job: FlinkJobFile = {
60
+ Job: { id: "failing-interval-job", interval: "10ms" },
61
+ default: async () => {
62
+ throw new Error("Interval job error");
63
+ },
64
+ };
65
+
66
+ autoRegisteredJobs.push(job);
67
+
68
+ app = new FlinkApp<TestContext>({ name: "test-job-errors-interval", disableHttpServer: true });
69
+ await app.start();
70
+
71
+ await new Promise((resolve) => setTimeout(resolve, 100));
72
+
73
+ expect(consoleErrorSpy).toHaveBeenCalled();
74
+ });
75
+
76
+ it("should run afterDelay 0ms job exactly once", async () => {
77
+ let runCount = 0;
78
+
79
+ const job: FlinkJobFile = {
80
+ Job: { id: "once-job-0ms", afterDelay: "0ms" },
81
+ default: async () => {
82
+ runCount++;
83
+ },
84
+ };
85
+
86
+ autoRegisteredJobs.push(job);
87
+
88
+ app = new FlinkApp<TestContext>({ name: "test-job-once-0ms", disableHttpServer: true });
89
+ await app.start();
90
+
91
+ await new Promise((resolve) => setTimeout(resolve, 100));
92
+
93
+ expect(runCount).toBe(1);
94
+ });
95
+ });
package/src/FlinkApp.ts CHANGED
@@ -790,20 +790,32 @@ export class FlinkApp<C extends FlinkContext> {
790
790
  }
791
791
 
792
792
  if (validateRes && !isError(handlerRes)) {
793
- const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
794
-
795
- if (!valid) {
796
- const formattedErrors = formatValidationErrors(validateRes.errors, handlerRes.data);
797
- log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response\n${formattedErrors}`);
798
-
799
- return res.status(500).json({
800
- status: 500,
801
- error: {
802
- id: v4(),
803
- title: "Bad response",
804
- detail: formattedErrors,
805
- },
806
- });
793
+ if (handlerRes.data === undefined) {
794
+ if (handlerRes.status !== 204) {
795
+ const detail =
796
+ "Response schema is defined but handler returned no data";
797
+ log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response - ${detail}`);
798
+ return res.status(500).json({
799
+ status: 500,
800
+ error: { id: v4(), title: "Bad response", detail },
801
+ });
802
+ }
803
+ } else {
804
+ const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
805
+
806
+ if (!valid) {
807
+ const formattedErrors = formatValidationErrors(validateRes.errors, handlerRes.data);
808
+ log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response\n${formattedErrors}`);
809
+
810
+ return res.status(500).json({
811
+ status: 500,
812
+ error: {
813
+ id: v4(),
814
+ title: "Bad response",
815
+ detail: formattedErrors,
816
+ },
817
+ });
818
+ }
807
819
  }
808
820
  }
809
821
 
@@ -1198,18 +1210,30 @@ export class FlinkApp<C extends FlinkContext> {
1198
1210
 
1199
1211
  this.scheduler.addSimpleIntervalJob(job);
1200
1212
  } else if (jobProps.afterDelay !== undefined) {
1201
- const job = new SimpleIntervalJob(
1202
- {
1203
- milliseconds: ms(jobProps.afterDelay),
1204
- runImmediately: false,
1205
- },
1206
- task,
1207
- {
1208
- id: jobProps.id,
1209
- preventOverrun: jobProps.singleton,
1210
- }
1211
- );
1212
- this.scheduler.addSimpleIntervalJob(job);
1213
+ const delayMs = ms(jobProps.afterDelay);
1214
+ if (delayMs === 0) {
1215
+ setImmediate(async () => {
1216
+ try {
1217
+ await jobFn({ ctx: this.ctx });
1218
+ } catch (err) {
1219
+ log.error(`Job ${jobProps.id} threw unhandled exception ${err}`);
1220
+ console.error(err);
1221
+ }
1222
+ });
1223
+ } else {
1224
+ const job = new SimpleIntervalJob(
1225
+ {
1226
+ milliseconds: delayMs,
1227
+ runImmediately: false,
1228
+ },
1229
+ task,
1230
+ {
1231
+ id: jobProps.id,
1232
+ preventOverrun: jobProps.singleton,
1233
+ }
1234
+ );
1235
+ this.scheduler.addSimpleIntervalJob(job);
1236
+ }
1213
1237
  } else {
1214
1238
  log.error(`Cannot register job ${jobProps.id} - no cron, interval or once set in ${__file}`);
1215
1239
  continue;