diff --git a/eslint.config.mjs b/eslint.config.mjs index 7f97268..e61b501 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,8 +3,29 @@ import eslint from '@eslint/js'; import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; +import stylistic from '@stylistic/eslint-plugin'; export default defineConfig( - eslint.configs.recommended, - tseslint.configs.recommended, + eslint.configs.recommended, + tseslint.configs.recommended, + stylistic.configs.recommended, + { + rules: { + '@stylistic/brace-style': ['error', '1tbs'], + '@stylistic/member-delimiter-style': [ + 'error', + { + multiline: { + delimiter: 'semi', + requireLast: true, + }, + singleline: { + delimiter: 'comma', + requireLast: false, + }, + }, + ], + '@stylistic/semi': ['error', 'always'], + }, + }, ); diff --git a/package-lock.json b/package-lock.json index 3ca26a9..33bf302 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@discoveryjs/json-ext": "^1.1.0", "@prisma/adapter-better-sqlite3": "^7.10.0", "@prisma/client": "^7.10.0", "@types/escape-html": "^1.0.4", @@ -20,18 +21,27 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@stylistic/eslint-plugin": "^5.10.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^26.1.2", "eslint": "^10.8.0", - "prettier": "^3.9.6", "prisma": "^7.9.1", "tsx": "^4.22.4", "typescript": "^5.9.3", "typescript-eslint": "^8.68.0" } }, + "node_modules/@discoveryjs/json-ext": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", @@ -1098,6 +1108,58 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -3837,22 +3899,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/prisma": { "version": "7.9.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.9.1.tgz", diff --git a/package.json b/package.json index b229641..4dfac64 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,13 @@ "start": "node dist/src/server.js", "dev": "tsx watch src/server.ts", "lint": "eslint 'src/**/*.ts'", - "format": "prettier --write 'src/**/*.ts'" + "format": "eslint 'src/**/*.ts' --fix" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { + "@discoveryjs/json-ext": "^1.1.0", "@prisma/adapter-better-sqlite3": "^7.10.0", "@prisma/client": "^7.10.0", "@types/escape-html": "^1.0.4", @@ -25,12 +26,12 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@stylistic/eslint-plugin": "^5.10.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^26.1.2", "eslint": "^10.8.0", - "prettier": "^3.9.6", "prisma": "^7.9.1", "tsx": "^4.22.4", "typescript": "^5.9.3", diff --git a/src/aggregateDaily.ts b/src/aggregateDaily.ts index 51234fa..c87d282 100644 --- a/src/aggregateDaily.ts +++ b/src/aggregateDaily.ts @@ -1,46 +1,53 @@ -import {aggregateSpeedscopeData} from "./repositories/profileRepository.js"; -import {AggregatedProfileType} from "../generated/prisma/enums.js"; -import {gzipSync} from "node:zlib"; -import {prisma} from "./prisma.js"; -import {AggregatedProfile} from "../generated/prisma/client.js"; -import config from "./config/config.js"; +import { + aggregateSpeedscopeData, + FrameTimings, +} from './repositories/profileRepository.js'; +import { AggregatedProfileType } from '../generated/prisma/enums.js'; +import { prisma } from './prisma.js'; +import { AggregatedProfile } from '../generated/prisma/client.js'; +import config from './config/config.js'; +import { jsonifyAndCompressFrameTimings, jsonifyAndCompressProfile } from './utils/jsonHelper.js'; +import { SpeedscopeFile } from './models/speedscope'; const end = new Date(); const start = new Date(end.getTime() - (24 * 60 * 60 * 1000)); // 1 day ago -const aggregatedProfiles: AggregatedProfile[] = await prisma.aggregatedProfile.findMany({ - where: { - startTime: { - gte: start, +const aggregateData = async (start: Date, end: Date) => { + const aggregatedProfiles: AggregatedProfile[] = await prisma.aggregatedProfile.findMany({ + where: { + startTime: { + gte: start, + }, + type: AggregatedProfileType.HOURLY, }, - type: AggregatedProfileType.HOURLY, - }, - orderBy: { - startTime: 'asc', - }, -}); + orderBy: { + startTime: 'asc', + }, + }); -if (!aggregatedProfiles || aggregatedProfiles.length === 0) { - console.log('No profiles found in the last day.'); - process.exit(0); -} + if (!aggregatedProfiles || aggregatedProfiles.length === 0) { + console.log('No profiles found in the last day.'); + process.exit(0); + } -const aggregatedData = aggregateSpeedscopeData( + const aggregatedData = aggregateSpeedscopeData( aggregatedProfiles, - `Daily aggregation (${start.toISOString()} to ${end.toISOString()})` -); + `Daily aggregation (${start.toISOString()} to ${end.toISOString()})`, + ); -console.log('Converting data to JSON...'); + const profileCount = aggregatedProfiles + .map(p => p.profileCount) + .reduce((a, b) => a + b, 0); -const profileJson = JSON.stringify(aggregatedData.file); -delete aggregatedData.file; + return { aggregatedData: aggregatedData, profileCount: profileCount }; +}; -const frameTimingJson = JSON.stringify(aggregatedData.frameTimings, (k, v) => { - if (v instanceof Map) { - return Array.from(v.entries()); - } - return v; -}); +const { aggregatedData, profileCount } = await aggregateData(start, end); +console.log('Compressing profile...'); +const compressedProfile = await jsonifyAndCompressProfile(aggregatedData.file as SpeedscopeFile); +delete aggregatedData.file; +console.log('Compressing frame timings...'); +const compressedFrameTimings = await jsonifyAndCompressFrameTimings(aggregatedData.frameTimings as FrameTimings); delete aggregatedData.frameTimings; console.log('Writing data to DB...'); @@ -50,12 +57,10 @@ await prisma.aggregatedProfile.create({ startTime: start, endTime: end, type: AggregatedProfileType.DAILY, - profileCount: aggregatedProfiles - .map((p) => p.profileCount) - .reduce((a, b) => a + b, 0), - speedscopeData: gzipSync(profileJson), - frameTimingData: gzipSync(frameTimingJson), - } + profileCount: profileCount, + speedscopeData: compressedProfile, + frameTimingData: compressedFrameTimings, + }, }); if (config.purgeHourlyAggregations) { diff --git a/src/aggregateHourly.ts b/src/aggregateHourly.ts index 9c41b52..692f829 100644 --- a/src/aggregateHourly.ts +++ b/src/aggregateHourly.ts @@ -1,8 +1,8 @@ -import {aggregateSpeedscopeData} from './repositories/profileRepository.js'; -import {prisma} from "./prisma.js"; -import {gzipSync} from "node:zlib"; -import {AggregatedProfileType, Profile} from "../generated/prisma/client.js"; -import config from "./config/config.js"; +import { aggregateSpeedscopeData } from './repositories/profileRepository.js'; +import { prisma } from './prisma.js'; +import { gzipSync } from 'node:zlib'; +import { AggregatedProfileType, Profile } from '../generated/prisma/client.js'; +import config from './config/config.js'; const end = new Date(); const start = new Date(end.getTime() - (60 * 60 * 1000)); // 1 hour ago @@ -24,8 +24,8 @@ if (profiles.length === 0) { process.exit(0); } const aggregatedData = aggregateSpeedscopeData( - profiles, - `Hourly aggregation (${start.toISOString()} to ${end.toISOString()})` + profiles, + `Hourly aggregation (${start.toISOString()} to ${end.toISOString()})`, ); await prisma.aggregatedProfile.create({ data: { @@ -40,7 +40,7 @@ await prisma.aggregatedProfile.create({ } return v; })), - } + }, }); if (config.purgeProfiles) { diff --git a/src/app.ts b/src/app.ts index 1402cac..c7961b3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,8 @@ import express from 'express'; import routes from './routes/routes.js'; import { errorHandler } from './middlewares/errorHandler.js'; -import path from "node:path"; -import {fileURLToPath} from "node:url"; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const app = express(); diff --git a/src/controllers/profileController.ts b/src/controllers/profileController.ts index 9bec5c3..639bdb6 100644 --- a/src/controllers/profileController.ts +++ b/src/controllers/profileController.ts @@ -1,8 +1,8 @@ import type { NextFunction, Request, Response } from 'express'; -import type {AggregatedProfileType, Profile} from "../../generated/prisma/client.js"; -import {prisma} from "../prisma.js"; -import {gunzipSync, gzipSync} from "node:zlib"; -import escapeHTML from "escape-html"; +import type { AggregatedProfileType, Profile } from '../../generated/prisma/client.js'; +import { prisma } from '../prisma.js'; +import { gunzipSync, gzipSync } from 'node:zlib'; +import escapeHTML from 'escape-html'; export const logProfile = async ( req: Request, @@ -22,7 +22,7 @@ export const logProfile = async ( } = req.body; const exists = await prisma.profile.findUnique({ - where: { id } + where: { id }, }); if (exists) { return res @@ -60,7 +60,7 @@ export const getProfile = async ( const { id } = req.params; const profile = await prisma.profile.findUnique({ - where: { id: id as string } + where: { id: id as string }, }) as Profile | null; if (!profile) { return res.status(404).json({ error: 'Profile not found' }); @@ -88,33 +88,33 @@ const createViewHtml = (profileUrl: string) => { }; export const viewLatestAggregation = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { const { type } = req.params; res.status(200).send(createViewHtml(`/#profileURL=/aggregation/latest/${type}`)); }; export const viewAggregation = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { const { id } = req.params; res.status(200).send(createViewHtml(`/#profileURL=/aggregation/${id}`)); }; export const viewProfile = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { const { id } = req.params; res.status(200).send(createViewHtml(`/#profileURL=/profile/${id}`)); }; export const getProfileMetadata = async ( - req: Request, - res: Response, - next: NextFunction, + req: Request, + res: Response, + next: NextFunction, ) => { try { const { id } = req.params; @@ -130,7 +130,7 @@ export const getProfileMetadata = async ( timestamp: true, environment: true, parserReport: true, - } + }, }) as Profile | null; if (!profile) { return res.status(404).json({ error: 'Profile not found' }); @@ -149,33 +149,33 @@ export const getProfileMetadata = async ( } catch (error) { next(error); } -} +}; export const getAggregations = async ( - req: Request, - res: Response, - next: NextFunction, + req: Request, + res: Response, + next: NextFunction, ) => { try { - const {type} = req.params; + const { type } = req.params; - const where = type ? {type: type as AggregatedProfileType} : {}; + const where = type ? { type: type as AggregatedProfileType } : {}; const aggregatedProfiles = await prisma.aggregatedProfile.findMany({ where, - orderBy: {endTime: 'desc'}, + orderBy: { endTime: 'desc' }, select: { id: true, startTime: true, endTime: true, type: true, profileCount: true, - } + }, }); res.status(200).json(aggregatedProfiles); } catch (error) { next(error); } -} +}; export const getLatestAggregation = async ( req: Request, @@ -201,9 +201,9 @@ export const getLatestAggregation = async ( }; export const getLatestAggregationMetadata = async ( - req: Request, - res: Response, - next: NextFunction, + req: Request, + res: Response, + next: NextFunction, ) => { try { const { type } = req.params; @@ -217,7 +217,7 @@ export const getLatestAggregationMetadata = async ( endTime: true, type: true, profileCount: true, - } + }, }); if (!aggregatedProfile) { return res.status(404).json({ error: 'No aggregated profiles found' }); @@ -227,12 +227,12 @@ export const getLatestAggregationMetadata = async ( } catch (error) { next(error); } -} +}; export const getLatestFrameTimingData = async ( - req: Request, - res: Response, - next: NextFunction, + req: Request, + res: Response, + next: NextFunction, ) => { try { const { type } = req.params; @@ -253,9 +253,9 @@ export const getLatestFrameTimingData = async ( }; export const getAggregationById = async ( - req: Request, - res: Response, - next: NextFunction, + req: Request, + res: Response, + next: NextFunction, ) => { try { const { id } = req.params; @@ -272,12 +272,12 @@ export const getAggregationById = async ( } catch (error) { next(error); } -} +}; export const getAggregationMetadataById = async ( - req: Request, - res: Response, - next: NextFunction, + req: Request, + res: Response, + next: NextFunction, ) => { try { const { id } = req.params; @@ -290,7 +290,7 @@ export const getAggregationMetadataById = async ( endTime: true, type: true, profileCount: true, - } + }, }); if (!aggregatedProfile) { return res.status(404).json({ error: 'Aggregated profile not found' }); @@ -300,12 +300,12 @@ export const getAggregationMetadataById = async ( } catch (error) { next(error); } -} +}; export const getAggregationFrameTimingDataById = async ( - req: Request, - res: Response, - next: NextFunction, + req: Request, + res: Response, + next: NextFunction, ) => { try { const { id } = req.params; @@ -322,4 +322,4 @@ export const getAggregationFrameTimingDataById = async ( } catch (error) { next(error); } -} +}; diff --git a/src/middlewares/authentication.ts b/src/middlewares/authentication.ts index 380eaa6..7057976 100644 --- a/src/middlewares/authentication.ts +++ b/src/middlewares/authentication.ts @@ -1,11 +1,11 @@ -import {timingSafeEqual} from "node:crypto"; -import config from "../config/config.js"; -import {Request, Response, NextFunction} from "express"; +import { timingSafeEqual } from 'node:crypto'; +import config from '../config/config.js'; +import { Request, Response, NextFunction } from 'express'; function requireAuth( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction, ) { const auth = req.headers.authorization; const expected = `Bearer ${config.logToken}`; diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index f610be5..3e47c40 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -11,7 +11,7 @@ export const errorHandler = ( req: Request, res: Response, // eslint-disable-next-line @typescript-eslint/no-unused-vars - next: NextFunction + next: NextFunction, ) => { console.error(err); const showMessage = config.nodeEnv === 'development'; diff --git a/src/prisma.ts b/src/prisma.ts index 5fc8a71..015c1d4 100644 --- a/src/prisma.ts +++ b/src/prisma.ts @@ -1,6 +1,6 @@ -import 'dotenv/config' -import {PrismaBetterSqlite3} from "@prisma/adapter-better-sqlite3"; -import {PrismaClient} from "../generated/prisma/client.js"; +import 'dotenv/config'; +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { PrismaClient } from '../generated/prisma/client.js'; const connectionString = `${process.env.DATABASE_URL}`; const adapter = new PrismaBetterSqlite3({ url: connectionString }); diff --git a/src/repositories/profileRepository.ts b/src/repositories/profileRepository.ts index c51bf22..90fb69d 100644 --- a/src/repositories/profileRepository.ts +++ b/src/repositories/profileRepository.ts @@ -1,20 +1,22 @@ import type { SpeedscopeFile, SpeedscopeFrame, } from '../models/speedscope.js'; -import {AggregatedProfile, Profile} from "../../generated/prisma/client"; -import {gunzipSync} from "node:zlib"; -import config from "../config/config.js"; +import { AggregatedProfile, Profile } from '../../generated/prisma/client'; +import { gunzipSync } from 'node:zlib'; +import config from '../config/config.js'; + +export type FrameTimings = Map>; export interface AggregationResult { file?: SpeedscopeFile; - frameTimings?: Map>; + frameTimings?: FrameTimings; } /** * @param data Array of profiles to aggregate. Must contain at least one entry. * @param name The name to assign to the aggregated profile. */ -export function aggregateSpeedscopeData(data: (Profile|AggregatedProfile)[], name: string): AggregationResult { +export function aggregateSpeedscopeData(data: (Profile | AggregatedProfile)[], name: string): AggregationResult { if (data.length === 0) { throw new Error('No data to aggregate!'); } @@ -30,7 +32,7 @@ export function aggregateSpeedscopeData(data: (Profile|AggregatedProfile)[], nam } const globalFrames = new Map(); - const frameTimings: Map> = new Map(); + const frameTimings: FrameTimings = new Map(); const globalFramesRev: SpeedscopeFrame[] = []; const globalSamples = new Map(); let json: SpeedscopeFile | undefined; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 8bdc744..1f0e61f 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -14,12 +14,12 @@ import { viewLatestAggregation, viewProfile, } from '../controllers/profileController.js'; -import {body, param} from 'express-validator'; +import { body, param } from 'express-validator'; import { handleValidationErrors } from '../middlewares/errorHandler.js'; import cors from 'cors'; -import config from "../config/config.js"; -import {AggregatedProfileType} from "../../generated/prisma/enums.js"; -import requireAuth from "../middlewares/authentication.js"; +import config from '../config/config.js'; +import { AggregatedProfileType } from '../../generated/prisma/enums.js'; +import requireAuth from '../middlewares/authentication.js'; const router = Router(); @@ -31,41 +31,41 @@ router.get( origin: config.allowedOrigin, }), handleValidationErrors, - getProfile + getProfile, ); router.get( - '/view/aggregation/latest/:type', [ - param('type').isString().notEmpty().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]), - ], - handleValidationErrors, - viewLatestAggregation + '/view/aggregation/latest/:type', [ + param('type').isString().notEmpty().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]), + ], + handleValidationErrors, + viewLatestAggregation, ); router.get( - '/view/aggregation/:id', [ - param('id').isInt(), - ], - handleValidationErrors, - viewAggregation + '/view/aggregation/:id', [ + param('id').isInt(), + ], + handleValidationErrors, + viewAggregation, ); router.get( - '/view/:id', [ - param('id').isString().notEmpty(), - ], - handleValidationErrors, - viewProfile -) + '/view/:id', [ + param('id').isString().notEmpty(), + ], + handleValidationErrors, + viewProfile, +); router.get( - '/metadata/:id', - [ - param('id').isString().notEmpty(), - ], - handleValidationErrors, - getProfileMetadata -) + '/metadata/:id', + [ + param('id').isString().notEmpty(), + ], + handleValidationErrors, + getProfileMetadata, +); router.post('/log', requireAuth, express.json({ limit: config.requestSizeLimit }), [ body('id').isString().notEmpty(), @@ -79,80 +79,80 @@ router.post('/log', requireAuth, express.json({ limit: config.requestSizeLimit } ], handleValidationErrors, logProfile); router.get( - '/aggregations', - getAggregations -) + '/aggregations', + getAggregations, +); router.get( - '/aggregations/:type', - [ - param('type').notEmpty().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]) - ], - handleValidationErrors, - getAggregations -) + '/aggregations/:type', + [ + param('type').notEmpty().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]), + ], + handleValidationErrors, + getAggregations, +); router.get( - '/aggregation/latest/:type', - cors({ - origin: config.allowedOrigin, - }), - [ - param('type').exists().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]) - ], - handleValidationErrors, - getLatestAggregation + '/aggregation/latest/:type', + cors({ + origin: config.allowedOrigin, + }), + [ + param('type').exists().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]), + ], + handleValidationErrors, + getLatestAggregation, ); router.get( - '/aggregation/latest/:type/metadata', - [ - param('type').exists().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]) - ], - handleValidationErrors, - getLatestAggregationMetadata + '/aggregation/latest/:type/metadata', + [ + param('type').exists().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]), + ], + handleValidationErrors, + getLatestAggregationMetadata, ); router.get( - '/aggregation/latest/:type/frame-timings', - [ - param('type').exists().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]) - ], - handleValidationErrors, - getLatestFrameTimingData + '/aggregation/latest/:type/frame-timings', + [ + param('type').exists().toUpperCase().isIn([AggregatedProfileType.HOURLY, AggregatedProfileType.DAILY]), + ], + handleValidationErrors, + getLatestFrameTimingData, ); router.get( - '/aggregation/:id', - cors({ - origin: config.allowedOrigin, - }), - [ - param('id').isInt(), - ], - handleValidationErrors, - getAggregationById + '/aggregation/:id', + cors({ + origin: config.allowedOrigin, + }), + [ + param('id').isInt(), + ], + handleValidationErrors, + getAggregationById, ); router.get( - '/aggregation/:id/metadata', - cors({ - origin: config.allowedOrigin, - }), - [ - param('id').isInt(), - ], - handleValidationErrors, - getAggregationMetadataById + '/aggregation/:id/metadata', + cors({ + origin: config.allowedOrigin, + }), + [ + param('id').isInt(), + ], + handleValidationErrors, + getAggregationMetadataById, ); router.get( - '/aggregation/:id/frame-timings', - [ - param('id').isInt(), - ], - handleValidationErrors, - getAggregationFrameTimingDataById + '/aggregation/:id/frame-timings', + [ + param('id').isInt(), + ], + handleValidationErrors, + getAggregationFrameTimingDataById, ); export default router; diff --git a/src/utils/jsonHelper.ts b/src/utils/jsonHelper.ts new file mode 100644 index 0000000..31d34a3 --- /dev/null +++ b/src/utils/jsonHelper.ts @@ -0,0 +1,34 @@ +import { SpeedscopeFile } from '../models/speedscope'; +import { pipeline } from 'node:stream/promises'; +import { Replacer, stringifyChunked } from '@discoveryjs/json-ext'; +import { arrayBuffer } from 'node:stream/consumers'; +import { FrameTimings } from '../repositories/profileRepository'; + +const jsonifyAndCompress = async ( + obj: unknown, + replacer?: Replacer, +): Promise> => { + const compressionStream = new CompressionStream('gzip'); + const [buffer] = await Promise.all([ + arrayBuffer(compressionStream.readable), + pipeline(stringifyChunked(obj, replacer), compressionStream.writable), + ]); + return Buffer.from(buffer); +}; + +export const jsonifyAndCompressProfile = async ( + profile: SpeedscopeFile, +): Promise> => { + return jsonifyAndCompress(profile); +}; + +export const jsonifyAndCompressFrameTimings = async ( + frameTimings: FrameTimings, +): Promise> => { + return jsonifyAndCompress(frameTimings, (k, v) => { + if (v instanceof Map) { + return Array.from(v.entries()); + } + return v; + }); +};