@vingitonga/elysia-winston-logger 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # elysia-winston-logger
2
+
3
+ To install dependencies:
4
+
5
+ ```bash
6
+ bun install
7
+ ```
8
+
9
+ To run:
10
+
11
+ ```bash
12
+ bun run index.ts
13
+ ```
14
+
15
+ This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
@@ -0,0 +1,115 @@
1
+ import process from 'node:process';
2
+ import { Elysia } from 'elysia';
3
+ import { getFormattingMethodName, getIP } from './helpers';
4
+ import { Log } from './log';
5
+ /**
6
+ * List of IP headers to check in order of priority.
7
+ *
8
+ * @remarks
9
+ * The order of the headers in this list determines the priority of the headers to use when determining the client IP address.
10
+ * If the first header is not present, the second header is checked, and so on.
11
+ */
12
+ export const headersToCheck = [
13
+ 'x-forwarded-for', // X-Forwarded-For is the de-facto standard header
14
+ 'x-real-ip', // Nginx proxy/FastCGI
15
+ 'x-client-ip', // Apache [mod_remoteip](https://httpd.apache.org/docs/2.4/mod/mod_remoteip.html#page-header)
16
+ 'cf-connecting-ip', // Cloudflare
17
+ 'fastly-client-ip', // Fastly
18
+ 'x-cluster-client-ip', // GCP
19
+ 'x-forwarded', // RFC 7239
20
+ 'forwarded-for', // RFC 7239
21
+ 'forwarded', // RFC 7239
22
+ 'appengine-user-ip', // GCP
23
+ 'true-client-ip', // Akamai and Cloudflare
24
+ 'cf-pseudo-ipv4', // Cloudflare
25
+ ];
26
+ /**
27
+ * Creates a middleware function that logs incoming requests and outgoing responses.
28
+ *
29
+ * @param logger - The logger object to use for logging. Defaults to console.
30
+ * @param options - The options object to configure the middleware.
31
+ * @param options.level - The log level to use. Defaults to "info".
32
+ * @param options.format - The log format to use. Can be a string or a function. Defaults to "json".
33
+ * @param options.skip - A function that returns true to skip logging for a specific request.
34
+ * @param options.includeHeaders - An array of headers to include in the log.
35
+ * @param options.ipHeaders - An array of headers to check for the client IP address.
36
+ *
37
+ * @returns A middleware function that logs incoming requests and outgoing responses.
38
+ */
39
+ export function ElysiaLogging(logger = console, options = {}) {
40
+ const { level = 'info', format = 'json', skip = undefined, includeHeaders = ['x-forwarded-for', 'authorization'], ipHeaders = headersToCheck } = options;
41
+ if (typeof format === 'string' && getFormattingMethodName(format) in Log.prototype === false) {
42
+ throw new Error(`Formatter '${format}' not found!`);
43
+ }
44
+ return new Elysia({ name: 'logger' })
45
+ .derive({ as: 'global' }, ({ request, server }) => {
46
+ const clientIP = server ? (getIP(request.headers, ipHeaders) ?? server.requestIP(request)?.address ?? undefined) : undefined;
47
+ return { ip: clientIP };
48
+ })
49
+ .onError(({ store, error }) => {
50
+ ;
51
+ store.error = error;
52
+ })
53
+ .onRequest(({ store }) => {
54
+ ;
55
+ store.requestStart = process.hrtime.bigint();
56
+ })
57
+ .onAfterHandle(({ store }) => {
58
+ ;
59
+ store.responseSize = undefined;
60
+ })
61
+ .onAfterResponse((ctx) => {
62
+ if (skip && typeof skip === 'function' && skip(ctx)) {
63
+ return;
64
+ }
65
+ let duration = 0;
66
+ if (ctx.store.requestStart !== undefined && typeof ctx.store.requestStart === 'bigint') {
67
+ duration = Number(process.hrtime.bigint() - ctx.store.requestStart);
68
+ }
69
+ const logObject = new Log({
70
+ request: {
71
+ ip: ctx.ip,
72
+ method: ctx.request.method,
73
+ url: {
74
+ path: ctx.path,
75
+ params: Object.fromEntries(new URLSearchParams(new URL(ctx.request.url).search)),
76
+ },
77
+ },
78
+ response: {
79
+ status_code: ctx.set.status,
80
+ time: duration,
81
+ },
82
+ });
83
+ if (ctx.store.error !== undefined) {
84
+ logObject.error = ctx.store.error;
85
+ }
86
+ // Add request ID if it exists
87
+ if (ctx.request.headers.has('x-request-id')) {
88
+ logObject.log.request.requestID = ctx.request.headers.get('x-request-id');
89
+ }
90
+ // Include headers
91
+ for (const header of includeHeaders) {
92
+ if (ctx.request.headers.has(header)) {
93
+ logObject.log.request.headers = {
94
+ ...logObject.log.request.headers,
95
+ [header]: ctx.request.headers.get(header),
96
+ };
97
+ }
98
+ }
99
+ let logOutput;
100
+ // If the log format is a function, call it and log the output
101
+ if (typeof format === 'function') {
102
+ logOutput = format(logObject.log);
103
+ }
104
+ else if (typeof format === 'string') {
105
+ const formattingMethod = getFormattingMethodName(format);
106
+ logOutput = logObject[formattingMethod]();
107
+ }
108
+ else {
109
+ throw new Error(`Invalid formatting method type '${typeof format}'!`);
110
+ }
111
+ // This invokes, e.g. `logger.info(logOutput)` for any given level
112
+ logger[level](logOutput);
113
+ })
114
+ .as('global');
115
+ }
@@ -0,0 +1,89 @@
1
+ import { headersToCheck } from './elysiaLogging';
2
+ const NANOSECOND = 1;
3
+ const MICROSECOND = 1e3 * NANOSECOND;
4
+ const MILLISECOND = 1e3 * MICROSECOND;
5
+ const SECOND = 1e3 * MILLISECOND;
6
+ /**
7
+ * Parses a Basic Authentication header string and returns an object containing the username and password.
8
+ *
9
+ * @param header - The Basic Authentication header string to parse.
10
+ *
11
+ * @returns An object containing the username and password, or undefined if the header is invalid.
12
+ */
13
+ export function parseBasicAuthHeader(header) {
14
+ const [type, authString] = header.split(' ');
15
+ // Check if authType is Basic and authString is valid base64
16
+ if (type !== 'Basic')
17
+ return undefined;
18
+ if (!isValidBase64(authString))
19
+ return undefined;
20
+ const authStringDecoded = Buffer.from(authString, 'base64').toString();
21
+ const [username, password] = authStringDecoded.includes(':') ? authStringDecoded.split(':', 2) : [undefined, undefined];
22
+ return {
23
+ type: type,
24
+ username: username ?? '',
25
+ password: password ?? '',
26
+ };
27
+ }
28
+ /**
29
+ * Formats a duration in nanoseconds to a human-readable string.
30
+ *
31
+ * @param durationInNanoseconds - The duration in nanoseconds to format.
32
+ *
33
+ * @returns A string representing the formatted duration.
34
+ */
35
+ export function formatDuration(durationInNanoseconds) {
36
+ if (durationInNanoseconds >= SECOND) {
37
+ return `${(durationInNanoseconds / SECOND).toPrecision(2)}s`;
38
+ }
39
+ else if (durationInNanoseconds >= MILLISECOND) {
40
+ return `${(durationInNanoseconds / MILLISECOND).toPrecision(4)}ms`;
41
+ }
42
+ else if (durationInNanoseconds >= MICROSECOND) {
43
+ return `${(durationInNanoseconds / MICROSECOND).toPrecision(4)}µs`;
44
+ }
45
+ else {
46
+ return `${durationInNanoseconds.toPrecision(4)}ns`;
47
+ }
48
+ }
49
+ /**
50
+ * Returns the IP address of the client making the request.
51
+ *
52
+ * @param headers - The headers object from the request.
53
+ * @param ipHeaders - An array of header names to check for the IP address. Defaults to common IP headers.
54
+ *
55
+ * @returns The IP address of the client, or undefined if not found.
56
+ */
57
+ export function getIP(headers, ipHeaders = headersToCheck) {
58
+ let clientIP = null;
59
+ for (const header of ipHeaders) {
60
+ clientIP = headers.get(header);
61
+ if (clientIP !== null && typeof clientIP === 'string') {
62
+ if (clientIP.includes(',')) {
63
+ clientIP = clientIP.split(',')[0];
64
+ }
65
+ break;
66
+ }
67
+ }
68
+ return clientIP;
69
+ }
70
+ /**
71
+ * Returns the name of the formatting method for a given format string.
72
+ *
73
+ * @param format - The format string to get the formatting method name for.
74
+ *
75
+ * @returns The name of the formatting method.
76
+ */
77
+ export function getFormattingMethodName(format) {
78
+ return `format${format.charAt(0).toUpperCase() + format.slice(1)}`;
79
+ }
80
+ /**
81
+ * Checks if a given string is a valid base64 encoded string.
82
+ *
83
+ * @param str - The string to be checked.
84
+ *
85
+ * @returns A boolean indicating whether the string is a valid base64 encoded string.
86
+ */
87
+ function isValidBase64(str) {
88
+ return Buffer.from(str, 'base64').toString('base64') === str;
89
+ }
package/dist/index.js ADDED
@@ -0,0 +1,45 @@
1
+ import util from 'node:util';
2
+ import { createLogger, format, transports } from 'winston';
3
+ import { ElysiaLogging } from './elysiaLogging';
4
+ import { LogFormat } from './logger-types';
5
+ const inspect = (value) => util.inspect(value, {
6
+ colors: true,
7
+ depth: null,
8
+ compact: false,
9
+ breakLength: 120,
10
+ });
11
+ const prettyMeta = format((info) => {
12
+ // Pretty-print the primary message if it's an object
13
+ if (typeof info.message !== 'string') {
14
+ info.message = inspect(info.message);
15
+ }
16
+ // Pretty-print every metadata field
17
+ for (const [key, value] of Object.entries(info)) {
18
+ if (['level', 'message', 'timestamp', 'stack'].includes(key))
19
+ continue;
20
+ if (value instanceof Error) {
21
+ info[key] = value.stack ?? value.message;
22
+ }
23
+ else if (typeof value === 'object' && value !== null) {
24
+ info[key] = inspect(value);
25
+ }
26
+ }
27
+ return info;
28
+ });
29
+ export const defaultLogger = createLogger({
30
+ level: Bun.env.LOG_LEVEL ?? 'info', // Note: process.env.LOG_LEVEL is more standard if you want Node compatibility, but Bun.env is fine for pure Bun
31
+ format: format.combine(format.colorize(), prettyMeta(), format.errors({ stack: true }), format.splat(), format.timestamp({ format: 'YYYY-MM-DD hh:mm:ss.SSS A' }), format.printf(({ timestamp, level, message }) => {
32
+ return `[${timestamp}] ${level}: ${message}`;
33
+ })),
34
+ transports: [new transports.Console()],
35
+ });
36
+ export const winstonLogger = (customLogger, options) => {
37
+ // If they pass their own logger, use it. Otherwise use your default.
38
+ const loggerToUse = customLogger ?? defaultLogger;
39
+ // Provide sensible defaults if they don't pass options
40
+ const pluginOptions = {
41
+ level: options?.level ?? 'info',
42
+ format: options?.format ?? LogFormat.JSON,
43
+ };
44
+ return ElysiaLogging(loggerToUse, pluginOptions);
45
+ };
package/dist/log.js ADDED
@@ -0,0 +1,83 @@
1
+ import { format } from 'date-fns';
2
+ import { toZonedTime } from 'date-fns-tz';
3
+ import { formatDuration, parseBasicAuthHeader } from './helpers';
4
+ /**
5
+ * Log class
6
+ *
7
+ * This class is used to format the log object in different ways.
8
+ * - formatJson() returns the log object as is
9
+ * - formatCommon() returns the log object as a common log format (NCSA) string
10
+ * - formatShort() returns the log object as a short string (perfect for dev console)
11
+ *
12
+ * @param log Log object
13
+ *
14
+ * @returns Log object
15
+ *
16
+ * @example
17
+ * const log = new Log(logObject);
18
+ * console.log(log.formatCommon());
19
+ * console.log(log.formatShort());
20
+ * console.log(log.formatJson());
21
+ **/
22
+ export class Log {
23
+ // Properties
24
+ logObject;
25
+ // Constructor
26
+ constructor(log) {
27
+ this.logObject = log;
28
+ }
29
+ // Getters and setters
30
+ set error(error) {
31
+ this.logObject.error = error;
32
+ }
33
+ get log() {
34
+ return this.logObject;
35
+ }
36
+ /**
37
+ * Simply return the log object and let the logger pretty print it
38
+ *
39
+ * @returns Log object as is
40
+ */
41
+ formatJson() {
42
+ return {
43
+ ...{
44
+ message: `${this.logObject.request.method} ${this.logObject.request.url.path} completed with status ${this.logObject.response.status_code} in ${formatDuration(this.logObject.response.time)}`,
45
+ },
46
+ ...this.logObject,
47
+ };
48
+ }
49
+ /**
50
+ * Formats the log object as a common log format (NCSA) string
51
+ *
52
+ * @see https://en.wikipedia.org/wiki/Common_Log_Format
53
+ *
54
+ *
55
+ * @returns Log object as a common log format (NCSA) string
56
+ */
57
+ formatCommon() {
58
+ // Get basic auth user if set, else "-"
59
+ const basicAuthUser = parseBasicAuthHeader(this.logObject.request.headers?.authorization ?? '')?.username ?? '-';
60
+ // @TODO: This is not yet exposed by Elysia (https://github.com/elysiajs/elysia/issues/324)
61
+ const requestProtocol = this.logObject.request.headers?.['x-forwarded-proto'] ?? 'HTTP/1.1';
62
+ // Formate date/time of the request (%d/%b/%Y:%H:%M:%S %z)
63
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
64
+ const zonedDate = toZonedTime(Date.now(), timeZone);
65
+ const formattedDate = format(zonedDate, 'dd/MMM/yyyy:HH:mm:ss xx');
66
+ // @TODO: This is not yet exposed by Elysia (https://github.com/elysiajs/elysia/issues/324)
67
+ const responseSize = undefined;
68
+ // Return formatted log string
69
+ return `${this.logObject.request.ip} - ${basicAuthUser} [${formattedDate}] "${this.logObject.request.method} ${this.logObject.request.url.path} ${requestProtocol}" ${this.logObject.response.status_code} ${responseSize ?? '-'}`;
70
+ }
71
+ /**
72
+ * Formats the log object as a short string (perfect for dev console)
73
+ *
74
+ * @returns Log object as a short string (perfect for dev console)
75
+ */
76
+ formatShort() {
77
+ const durationInNanoseconds = this.logObject.response.time;
78
+ const timeMessage = formatDuration(durationInNanoseconds);
79
+ const queryString = Object.keys(this.logObject.request.url.params).length ? `?${new URLSearchParams(this.logObject.request.url.params).toString()}` : '';
80
+ const requestUri = this.logObject.request.url.path + queryString;
81
+ return `[${this.logObject.request.ip}] ${this.logObject.request.method} ${requestUri} ${this.logObject.response.status_code} (${timeMessage})`;
82
+ }
83
+ }
@@ -0,0 +1,7 @@
1
+ //
2
+ export const LogFormat = {
3
+ JSON: 'json',
4
+ COMMON: 'common',
5
+ SHORT: 'short',
6
+ // Add other methods here
7
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@vingitonga/elysia-winston-logger",
3
+ "version": "0.1.0",
4
+ "description": "Winston logger plugin for Elysia",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "type": "module",
9
+ "scripts": {
10
+ "build": "rm -rf dist && bun build ./src/index.ts --outdir ./dist --target node && tsc",
11
+ "prepublishOnly": "bun run build",
12
+ "format": "biome format --write .",
13
+ "lint": "biome lint --write ."
14
+ },
15
+ "devDependencies": {
16
+ "@biomejs/biome": "2.5.3",
17
+ "@types/bun": "latest",
18
+ "elysia": "^1.4.29",
19
+ "typescript": "^5",
20
+ "winston": "^3.19.0"
21
+ },
22
+ "peerDependencies": {
23
+ "elysia": ">=1.4.0",
24
+ "winston": ">=3.0.0"
25
+ },
26
+ "dependencies": {
27
+ "date-fns": "^4.4.0",
28
+ "date-fns-tz": "^3.2.0"
29
+ }
30
+ }