This commit is contained in:
Zakary Timson 2025-04-17 10:20:46 -04:00
commit c6f10f9a32
9 changed files with 1402 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.idea
vscode
node_modules

10
Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM node:alpine
ARG PORT
ENV PORT=$PORT
COPY . .
RUN npm install && npm run build
CMD ["node", "dist/main.js"]
EXPOSE $PORT

1191
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "stock-exporter",
"version": "1.0.0",
"description": "Export stock prices to prometheus",
"main": "main.ts",
"scripts": {
"build": "tsc",
"start": "ts-node src/main.ts"
},
"author": "ztimson",
"license": "MIT",
"dependencies": {
"@ztimson/utils": "^0.23.22",
"compression": "^1.7.5",
"cors": "^2.8.5",
"express": "^4.21.2"
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.0",
"ts-node": "^11.0.0-beta.1",
"typescript": "^5.8.3"
}
}

10
src/environment.ts Normal file
View File

@ -0,0 +1,10 @@
import packageJson from '../package.json';
import process from 'node:process';
export const environment = {
port: process.env['port'] ?? 3000,
production: !process.env.NODE_ENV || ['prod', 'production'].includes(process.env.NODE_ENV.toLowerCase()),
tickers: process.env['tickers']?.split(',') ?? [],
token: process.env['token'],
version: packageJson.version
}

54
src/error.middleware.ts Normal file
View File

@ -0,0 +1,54 @@
import {BadRequestError, CustomError, ForbiddenError, InternalServerError, Logger} from '@ztimson/utils';
import {Response, NextFunction} from 'express';
import {environment} from './environment';
export class PermissionsError extends ForbiddenError {
constructor(message?: string) {
super(message.replace(/^.+?:/g, 'Permissions required:'));
}
}
export class ValidationError extends BadRequestError {
constructor(message: string = 'Unknown') {
super(`Validation Error: ${message}`);
}
static from(err: any) {
const f = err.fields;
const k = Object.keys(f);
return new ValidationError(f[k[0]].message);
}
}
export const errorHandler = fn => (req: any, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
const logger = new Logger();
export const errorMiddleware = (err: Error | CustomError, req?: any, resp?: Response, next?: NextFunction) => {
const e: CustomError = CustomError.instanceof(err) ? <CustomError>err :
/Requires (one of|all):/g.test(err.message) ? PermissionsError.from(err) :
err?.stack?.includes('SqliteError') ? BadRequestError.from(err) :
err?.stack?.includes('ValidateError') ? ValidationError.from(err) :
InternalServerError.from(err);
const clientError = e.code >= 400 && e.code < 500;
if(req) logger[clientError ? (environment.production ? 'debug' : 'warn') : 'error'](`[${req.ipV4}] ${req.method} ${req.url ? decodeURI(req.url) : null} -> ${e.code}`);
if(!clientError || !environment.production) {
const message = (e.stack || `${e.message} (No Stack Trace)`);
logger[clientError ? 'warn' : 'error'](message);
}
if(resp) {
resp['logged'] = true;
resp.statusMessage = e.message.replaceAll('\n', ' ');
resp.contentType('application/json').status(e.code).send({
method: req?.method,
url: req?.url,
code: e.code,
timestamp: (new Date()).toISOString(),
message: e.message,
stack: environment.production ? undefined : e.stack,
});
}
}

36
src/logger.middleware.ts Normal file
View File

@ -0,0 +1,36 @@
import {Response, NextFunction} from 'express';
import {environment} from './environment';
export function formatIp(ip: string) {
if(!ip) return null;
const ipv4 = ip.split(':').splice(-1)[0];
if(ipv4 == '1') return '127.0.0.1';
return ipv4;
}
export function loggerMiddleware(req: any, resp: Response, next: NextFunction) {
req.start = new Date().getTime();
req.ipV4 = formatIp(req.ip);
if(req.path == '/api/healthcheck' && environment.production)
return next();
console.debug(`[${req.ipV4}] ${req.method} ${decodeURI(req.url)}`);
resp.on('finish', () => {
if(resp['logged']) return;
let units = 'ms', elapsed: any = new Date().getTime() - req.start;
if(elapsed > 1000) {
units = 's';
elapsed = (elapsed / 1000).toFixed(1);
}
if(units == 's' && elapsed > 100) {
units = 'm';
elapsed = (elapsed / 60).toFixed(1);
}
const serverError = resp.statusCode >= 500 && resp.statusCode < 600;
const clientError = resp.statusCode >= 400 && resp.statusCode < 500;
console[serverError ? 'error' : (clientError && !environment.production) ? 'warn' : 'log'](`(${elapsed}${units}) [${req.ipV4}] ${req?.session?.user ? `(${req.session?.user?.username}) ` : ''}${req.method} ${decodeURI(req.url)} -> ${resp.statusCode}`);
});
next();
}

52
src/main.ts Normal file
View File

@ -0,0 +1,52 @@
import compression from 'compression';
import express from 'express';
import http from 'node:http';
import * as process from 'node:process';
import {environment} from './environment';
import {errorHandler, errorMiddleware} from './error.middleware';
import {loggerMiddleware} from './logger.middleware';
if(!environment.token) throw new Error('Please provide a Finnhub token');
if(!environment.tickers.length) throw new Error('Please provide a CSV list of tickers');
const app = express();
const httpServer: http.Server = http.createServer(app);
app.use(compression());
app.use(loggerMiddleware);
// Graceful shutdown
let shuttingDown = false;
['SIGHUP', 'SIGINT', 'SIGTERM'].forEach((signal) => {
process.on(signal, (signal, value) => {
if(shuttingDown) return; // Already shutting down
shuttingDown = true;
console.warn(`${signal} Received, shutting down...`);
httpServer.close(() => setTimeout(() => process.exit(128 + value), 1000));
});
});
app.get('/', errorHandler(async (req, res) => {
const results = await Promise.all(environment.tickers.map(ticker =>
fetch(`https://finnhub.io/api/v1/quote?symbol=${ticker}&token=${environment.token}`)
.then(resp => resp.json())));
// const metrics = results.map(r => `# HELP stock_price_${result.symbol} Stock price for ${result.symbol}
// # TYPE stock_price_${result.symbol} gauge
// stock_price_${result.symbol} ${result.c}`).join('\n');
const metrics = JSON.stringify(results);
res.contentType('text/plain').send(metrics);
}));
// Error handling (must come last)
app.use(errorMiddleware);
process.on('unhandledRejection', (event) => errorMiddleware(<any>event));
// Startup
try {
httpServer.listen(environment.port, async () =>
console.log(`Listening on: http://localhost:${environment.port}`));
} catch(err) {
console.error(err.message);
process.exit(1);
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"include": [
"src"
],
"compilerOptions": {
"baseUrl": ".",
"esModuleInterop": true,
"experimentalDecorators": true,
"inlineSourceMap": true,
"inlineSources": true,
"lib": ["dom", "ESNext", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"resolveJsonModule": true,
"rootDir": "src",
"skipLibCheck": true,
"target": "ESNext"
}
}