From 54c7191a04728641d31415ad5a658ef3e8162293 Mon Sep 17 00:00:00 2001 From: confused-Techie Date: Mon, 24 Nov 2025 22:52:40 -0800 Subject: [PATCH 1/5] Create `node.version=2`, timecop, and support `Server-Timing` --- src/context.js | 22 +++++++++++- src/controllers/getPackagesPackageName.js | 31 +++++++++++------ src/models/timecop.js | 41 +++++++++++++++++++++++ src/setupEndpoints.js | 24 +++++++++++-- 4 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 src/models/timecop.js diff --git a/src/context.js b/src/context.js index a6140725..bed1969b 100644 --- a/src/context.js +++ b/src/context.js @@ -1,7 +1,9 @@ // The CONST Context - Enables access to all other modules within the system // By passing this object to everywhere needed allows not only easy access // but greater control in mocking these later on -module.exports = { +const Timecop = require("./models/timecop.js"); + +const CTX = { logger: require("./logger.js"), database: require("./database/_export.js"), webhook: require("./webhook.js"), @@ -23,3 +25,21 @@ module.exports = { constructPackageObjectJSON: require("./models/constructPackageObjectJSON.js"), }, }; + +module.exports = { + // === Simple Context Object, ControllerV1: Simple Object + obj: CTX, + // === On Demand Built Object, ControllerV2: Takes req, res, params objects and returns + // A context as the "World" + build(req, res, params) { + return { + req: req, + res: res, + params: params, + timecop: new Timecop(), + ...CTX, + callStack: new CTX.callStack(), // Put after spread operator on CTX so it + // overwrites the original callStack + }; + } +}; diff --git a/src/controllers/getPackagesPackageName.js b/src/controllers/getPackagesPackageName.js index 5cff4d8f..ef29362a 100644 --- a/src/controllers/getPackagesPackageName.js +++ b/src/controllers/getPackagesPackageName.js @@ -3,6 +3,7 @@ */ module.exports = { + version: 2, docs: { summary: "Show package details.", responses: { @@ -42,16 +43,19 @@ module.exports = { * @param {object} context - The Endpoint Context. * @returns {sso} */ - async logic(params, context) { - // Lets first check if this is a bundled package we should return - const isBundled = context.bundled.isNameBundled(params.packageName); + async logic(ctx) { + const { params } = ctx; + // Lets first check if this is a bundled package we should return + ctx.timecop.start("bundle"); + const isBundled = ctx.bundled.isNameBundled(params.packageName); + ctx.timecop.end("bundle"); if (isBundled.ok && isBundled.content) { // This is in fact a bundled package - const bundledData = context.bundled.getBundledPackage(params.packageName); + const bundledData = ctx.bundled.getBundledPackage(params.packageName); if (!bundledData.ok) { - const sso = new context.sso(); + const sso = new ctx.sso(); return sso .notOk() @@ -59,32 +63,37 @@ module.exports = { .addCalls("bundled.isBundled", isBundled); } - const sso = new context.sso(); + const sso = new ctx.sso(); return sso.isOk().addContent(bundledData.content); } - let pack = await context.database.getPackageByName( + ctx.timecop.start("db"); + + let pack = await ctx.database.getPackageByName( params.packageName, true ); + ctx.timecop.end("db"); if (!pack.ok) { - const sso = new context.sso(); + const sso = new ctx.sso(); return sso.notOk().addContent(pack).addCalls("db.getPackageByName", pack); } - pack = await context.models.constructPackageObjectFull(pack.content); + ctx.timecop.start("construct"); + pack = await ctx.models.constructPackageObjectFull(pack.content); if (params.engine !== false) { // query.engine returns false if no valid query param is found. // before using engineFilter we need to check the truthiness of it. - pack = await context.utils.engineFilter(pack, params.engine); + pack = await ctx.utils.engineFilter(pack, params.engine); } + ctx.timecop.end("construct"); - const sso = new context.sso(); + const sso = new ctx.sso(); return sso.isOk().addContent(pack); }, diff --git a/src/models/timecop.js b/src/models/timecop.js new file mode 100644 index 00000000..45dc6b70 --- /dev/null +++ b/src/models/timecop.js @@ -0,0 +1,41 @@ + +module.exports = +class Timecop { + constructor() { + this.timetable = {}; + } + + start(service) { + this.timetable[service] = { + start: performance.now(), + end: undefined, + duration: undefined + }; + } + + end(service) { + if (!this.timetable[service]) { + this.timetable[service] = {}; + this.timetable[service].start = 0; // Wildly incorrect date, more likely + // to be caught rather than letting the time taken be 0ms + } + this.timetable[service].end = performance.now(); + this.timetable[service].duration = + this.timetable[service].end - + this.timetable[service].start; + } + + toHeader() { + let str = ""; + + for (const service in this.timetable) { + if (str.length > 0) { + str = str + ", "; + } + + str = str + `${service};dur=${Number(this.timetable[service].duration).toFixed(2)}`; + } + + return str; + } +} diff --git a/src/setupEndpoints.js b/src/setupEndpoints.js index 33fd4b58..09b80cda 100644 --- a/src/setupEndpoints.js +++ b/src/setupEndpoints.js @@ -3,7 +3,8 @@ const rateLimit = require("express-rate-limit"); const { MemoryStore } = require("express-rate-limit"); const endpoints = require("./controllers/endpoints.js"); -const context = require("./context.js"); +const CONTEXT = require("./context.js"); +const context = CONTEXT.obj; const app = express(); @@ -56,6 +57,7 @@ const endpointHandler = async function (node, req, res) { await node.preLogic(req, res, context); } + const sharedCtx = CONTEXT.build(req, res, params); let obj; try { @@ -64,7 +66,16 @@ const endpointHandler = async function (node, req, res) { // If it's a raw endpoint, they must handle all other steps manually return; } else { - obj = await node.logic(params, context); + switch(node.version) { + case 2: + obj = await node.logic(sharedCtx); + break; + case 1: + default: + // Previous default, implicit version 1 behavior + obj = await node.logic(params, context); + break; + } } } catch (err) { // The main logic request has failed. We will generate our own return obj, @@ -81,6 +92,15 @@ const endpointHandler = async function (node, req, res) { await node.postLogic(req, res, context); } + // Before handling our return check again for our node.version to check for + // extra steps + if (node.version === 2) { + // Server-Timing Header check + if (Object.keys(sharedCtx.timecop.timetables).length > 0) { + res.append("Server-Timing", sharedCtx.timecop.toHeader()); + } + } + obj.addGoodStatus(node.endpoint.successStatus); obj.handleReturnHTTP(req, res, context); From d1e5fb56203c7f629f5decc018ca786cf4ed0de6 Mon Sep 17 00:00:00 2001 From: confused-Techie Date: Mon, 24 Nov 2025 23:20:10 -0800 Subject: [PATCH 2/5] Don't modify original `context`, add ctx builder in `setupEndpoints` --- src/context.js | 22 +--------------------- src/setupEndpoints.js | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/context.js b/src/context.js index bed1969b..a6140725 100644 --- a/src/context.js +++ b/src/context.js @@ -1,9 +1,7 @@ // The CONST Context - Enables access to all other modules within the system // By passing this object to everywhere needed allows not only easy access // but greater control in mocking these later on -const Timecop = require("./models/timecop.js"); - -const CTX = { +module.exports = { logger: require("./logger.js"), database: require("./database/_export.js"), webhook: require("./webhook.js"), @@ -25,21 +23,3 @@ const CTX = { constructPackageObjectJSON: require("./models/constructPackageObjectJSON.js"), }, }; - -module.exports = { - // === Simple Context Object, ControllerV1: Simple Object - obj: CTX, - // === On Demand Built Object, ControllerV2: Takes req, res, params objects and returns - // A context as the "World" - build(req, res, params) { - return { - req: req, - res: res, - params: params, - timecop: new Timecop(), - ...CTX, - callStack: new CTX.callStack(), // Put after spread operator on CTX so it - // overwrites the original callStack - }; - } -}; diff --git a/src/setupEndpoints.js b/src/setupEndpoints.js index 09b80cda..a1f7c54e 100644 --- a/src/setupEndpoints.js +++ b/src/setupEndpoints.js @@ -3,8 +3,7 @@ const rateLimit = require("express-rate-limit"); const { MemoryStore } = require("express-rate-limit"); const endpoints = require("./controllers/endpoints.js"); -const CONTEXT = require("./context.js"); -const context = CONTEXT.obj; +const context = require("./context.js"); const app = express(); @@ -40,6 +39,22 @@ const authLimit = rateLimit({ }, }); +// TODO: Once all controllers are migrated to v2, or all endpoint tests are HTTP +// based, we can move this to `./context.js`, but until then unit tests rely on +// the structure of Context, and instead of changing it, we can define the builder here +const Timecop = require("./models/timecop.js"); +const buildContext = (req, res, params) => { + return { + req: req, + res: res, + params: params, + timecop: new Timecop(), + ...context, + callStack: new context.callStack(), // Put after spread operator on CTX so + // it overwrites the original callstack uninitialized class + }; +}; + // Set express defaults app.set("trust proxy", true); @@ -57,7 +72,7 @@ const endpointHandler = async function (node, req, res) { await node.preLogic(req, res, context); } - const sharedCtx = CONTEXT.build(req, res, params); + const sharedCtx = buildContext(req, res, params); let obj; try { From 6ba1dc655e773bb90667a3c5eb18f1410b01ceb8 Mon Sep 17 00:00:00 2001 From: confused-Techie Date: Mon, 24 Nov 2025 23:43:04 -0800 Subject: [PATCH 3/5] Properly apply Server-Timing data --- src/setupEndpoints.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/setupEndpoints.js b/src/setupEndpoints.js index a1f7c54e..29c8ae22 100644 --- a/src/setupEndpoints.js +++ b/src/setupEndpoints.js @@ -111,7 +111,7 @@ const endpointHandler = async function (node, req, res) { // extra steps if (node.version === 2) { // Server-Timing Header check - if (Object.keys(sharedCtx.timecop.timetables).length > 0) { + if (Object.keys(sharedCtx.timecop.timetable).length > 0) { res.append("Server-Timing", sharedCtx.timecop.toHeader()); } } From 59c23f6d3bb1f889ac0c794f1cfb380a33b0032d Mon Sep 17 00:00:00 2001 From: confused-Techie Date: Mon, 24 Nov 2025 23:43:16 -0800 Subject: [PATCH 4/5] Migrate tests to HTTP only --- tests/full/getPackagesPackageName.test.js | 38 ++++++++++++++ tests/http/getPackagesPackageName.test.js | 60 ----------------------- 2 files changed, 38 insertions(+), 60 deletions(-) create mode 100644 tests/full/getPackagesPackageName.test.js delete mode 100644 tests/http/getPackagesPackageName.test.js diff --git a/tests/full/getPackagesPackageName.test.js b/tests/full/getPackagesPackageName.test.js new file mode 100644 index 00000000..7ef6255a --- /dev/null +++ b/tests/full/getPackagesPackageName.test.js @@ -0,0 +1,38 @@ +const supertest = require("supertest"); +const app = require("../../src/setupEndpoints.js"); +const database = require("../../src/database/_export.js"); +const genPackage = require("../helpers/package.jest.js"); + +describe("Behaves as expected", () => { + test("Returns 404 when a package doesn't exist", async () => { + const res = await supertest(app).get("/api/packages/anything"); + + expect(res).toHaveHTTPCode(404); + expect(res.body.message).toBe("Not Found"); + }); + + test("Returns a package on success", async () => { + await database.insertNewPackage( + genPackage("https://github.com/confused-Techie/get-package-test", { + versions: [ "1.1.0", "1.0.0" ] + }) + ); + + const res = await supertest(app).get("/api/packages/get-package-test"); + + expect(res).toHaveHTTPCode(200); + expect(res.body.name).toBe("get-package-test"); + expect(res.body.owner).toBe("confused-Techie"); + + await database.removePackageByName("get-package-test", true); + }); + + test("Returns a bundled package without it existing in the database", async () => { + const res = await supertest(app).get("/api/packages/settings-view"); + + expect(res).toHaveHTTPCode(200); + expect(res.body.name).toBe("settings-view"); + expect(res.body.owner).toBe("pulsar-edit"); + expect(res.body.repository.url).toBe("https://github.com/pulsar-edit/pulsar"); + }); +}); diff --git a/tests/http/getPackagesPackageName.test.js b/tests/http/getPackagesPackageName.test.js deleted file mode 100644 index f1383737..00000000 --- a/tests/http/getPackagesPackageName.test.js +++ /dev/null @@ -1,60 +0,0 @@ -const endpoint = require("../../src/controllers/getPackagesPackageName.js"); -const database = require("../../src/database/_export.js"); -const context = require("../../src/context.js"); - -const genPackage = require("../helpers/package.jest.js"); - -describe("Behaves as expected", () => { - test("Returns 'not_found' when package doesn't exist", async () => { - const sso = await endpoint.logic( - { - engine: false, - packageName: "anything", - }, - context - ); - - expect(sso.ok).toBe(false); - expect(sso.content.short).toBe("not_found"); - }); - - test("Returns package on success", async () => { - await database.insertNewPackage( - genPackage("https://github.com/confused-Techie/get-package-test", { - versions: ["1.1.0", "1.0.0"], - }) - ); - - const sso = await endpoint.logic( - { - engine: false, - packageName: "get-package-test", - }, - context - ); - - expect(sso.ok).toBe(true); - expect(sso.content.name).toBe("get-package-test"); - expect(sso.content.owner).toBe("confused-Techie"); - expect(sso).toMatchEndpointSuccessObject(endpoint); - await database.removePackageByName("get-package-test", true); - }); - - test("Returns a bundled package without it existing in the database", async () => { - const sso = await endpoint.logic( - { - engine: false, - packageName: "settings-view", - }, - context - ); - - expect(sso.ok).toBe(true); - expect(sso.content.name).toBe("settings-view"); - expect(sso.content.owner).toBe("pulsar-edit"); - expect(sso.content.repository.url).toBe( - "https://github.com/pulsar-edit/pulsar" - ); - expect(sso).toMatchEndpointSuccessObject(endpoint); - }); -}); From 1ef2a7a3aa2c9678ebe128158e65147f177a6d30 Mon Sep 17 00:00:00 2001 From: confused-Techie Date: Mon, 24 Nov 2025 23:51:30 -0800 Subject: [PATCH 5/5] Add test for `Server-Timing` --- tests/full/getPackagesPackageName.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/full/getPackagesPackageName.test.js b/tests/full/getPackagesPackageName.test.js index 7ef6255a..0d764b99 100644 --- a/tests/full/getPackagesPackageName.test.js +++ b/tests/full/getPackagesPackageName.test.js @@ -35,4 +35,10 @@ describe("Behaves as expected", () => { expect(res.body.owner).toBe("pulsar-edit"); expect(res.body.repository.url).toBe("https://github.com/pulsar-edit/pulsar"); }); + + test("Adheres to `Server-Timing` Specification", async () => { + const res = await supertest(app).get("/api/packages/i-dont-exist"); + + expect(res.headers["server-timing"]).toBeTypeof("string"); + }); });