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