init
This commit is contained in:
		
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
.idea
 | 
			
		||||
vscode
 | 
			
		||||
node_modules
 | 
			
		||||
							
								
								
									
										10
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								Dockerfile
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										1191
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										26
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								package.json
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										10
									
								
								src/environment.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										54
									
								
								src/error.middleware.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										36
									
								
								src/logger.middleware.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										52
									
								
								src/main.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										20
									
								
								tsconfig.json
									
									
									
									
									
										Normal 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"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user