init
This commit is contained in:
commit
c6f10f9a32
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"
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user