diff --git a/src/preset/bun-handler/index.ts b/src/preset/bun-handler/index.ts index 95dc354..c722bec 100644 --- a/src/preset/bun-handler/index.ts +++ b/src/preset/bun-handler/index.ts @@ -11,6 +11,11 @@ import { providePageRender } from '../node/handler/providers'; import { SpecificExtras } from '../node/handler/utils'; import { HandlerProviders } from './providers'; +/** + * Возвращает preset с зависимостями для формирования обработчика входящего http-запроса. + * @param customize Получит функцию с помощью которой можно переопределить предустановленные провайдеры. + * @return Preset. + */ export function PresetBunHandler(customize?: PresetTuner) { const preset = createPreset(); diff --git a/src/preset/bun-handler/providers/index.tsx b/src/preset/bun-handler/providers/index.tsx index a86f30e..c4f3e9d 100644 --- a/src/preset/bun-handler/providers/index.tsx +++ b/src/preset/bun-handler/providers/index.tsx @@ -21,9 +21,10 @@ import { getFetchExtraAborting, getFetchLogging, } from '../../isomorphic/utils'; -import { getForwardedHeaders, getPageResponseFormat } from '../../server/utils'; import { PageAssets } from '../../isomorphic/types'; import { RESPONSE_EVENT_TYPE } from '../../isomorphic/constants'; +import { getPageResponseFormat } from '../../server/utils/get-page-response-format'; +import { getForwardedHeaders } from '../../server/utils/get-forwarded-headers'; export const HandlerProviders = { handlerMain(resolve: Resolve) { diff --git a/src/preset/bun/index.ts b/src/preset/bun/index.ts index 66a28eb..ec41e64 100644 --- a/src/preset/bun/index.ts +++ b/src/preset/bun/index.ts @@ -1,4 +1,3 @@ -/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ import { createPreset } from '../../di'; import { KnownToken } from '../../tokens'; import { PresetTuner } from '../isomorphic'; @@ -6,8 +5,13 @@ import { provideBaseConfig } from '../isomorphic/providers'; import { provideKnownHttpApiHosts, provideSsrBridgeServerSide } from '../node/node/providers'; import { BunProviders } from './providers'; -// @todo возможно стоит переименовать в PresetServer (так как в теории это можно использовать не только в Bun но и в Deno, Node.js) +/** + * Возвращает preset с зависимостями для запуска приложения в Bun. + * @param customize Получит функцию с помощью которой можно переопределить предустановленные провайдеры. + * @return Preset. + */ export function PresetBun(customize?: PresetTuner) { + // @todo возможно стоит переименовать в PresetServer (так как в теории это можно использовать не только в Bun но и в Deno, Node.js) const preset = createPreset(); // config diff --git a/src/preset/bun/providers/index.ts b/src/preset/bun/providers/index.ts index 851ecdd..347489c 100644 --- a/src/preset/bun/providers/index.ts +++ b/src/preset/bun/providers/index.ts @@ -10,15 +10,13 @@ import { getCurrentHub, init, runWithAsyncContext } from '@sentry/bun'; import { createSentryHandler } from '../../../log/handler/sentry'; import { provideFetch } from '../../isomorphic/providers'; import { ServerHandler, ServerMiddleware } from '../../server/types'; -import { - applyServerMiddleware, - healthCheck, - getServeLogging, - getServeErrorLogging, - getServeMeasuring, -} from '../../server/utils'; -import PromClient from 'prom-client'; import { statsHandler } from '../utils'; +import { getHealthCheck } from '../../server/utils/get-health-check'; +import { getServeLogging } from '../../server/utils/get-serve-logging'; +import { getServeErrorLogging } from '../../server/utils/get-serve-error-logging'; +import { getServeMeasuring } from '../../server/utils/get-serve-measuring'; +import { applyServerMiddleware } from '../../server/utils/apply-server-middleware'; +import PromClient from 'prom-client'; export const BunProviders = { configSource(): ConfigSource { @@ -88,7 +86,7 @@ export const BunProviders = { serviceRoutes(): Array<[string, ServerHandler]> { return [ // служебные маршруты (без промежуточных слоев) - ['/healthcheck', healthCheck()], + ['/healthcheck', getHealthCheck()], ['/stats', statsHandler()], ]; }, diff --git a/src/preset/isomorphic/utils/__test__/hide-first-id.test.ts b/src/preset/isomorphic/utils/__test__/hide-first-id.test.ts new file mode 100644 index 0000000..5e0610e --- /dev/null +++ b/src/preset/isomorphic/utils/__test__/hide-first-id.test.ts @@ -0,0 +1,24 @@ +import { hideFirstId } from '../hide-first-id'; + +describe('hideFirstId', () => { + const cases = [ + { + input: '/api/v2/something/123456/some-bff/123456', + output: ['/api/v2/something/{id}/some-bff/123456', 123456], + }, + { + input: '/api/v2/something/222/some-bff/333', + output: ['/api/v2/something/{id}/some-bff/333', 222], + }, + { + input: '/api/v2/something/45320/some-bff', + output: ['/api/v2/something/{id}/some-bff', 45320], + }, + ]; + + it('should replace first id only', () => { + cases.forEach(({ input, output }) => { + expect(hideFirstId(input)).toEqual(output); + }); + }); +}); diff --git a/src/preset/isomorphic/utils/hide-first-id.ts b/src/preset/isomorphic/utils/hide-first-id.ts new file mode 100644 index 0000000..e71eefe --- /dev/null +++ b/src/preset/isomorphic/utils/hide-first-id.ts @@ -0,0 +1,14 @@ +/** + * Преобразует строку вида: + * "/api/v2/something/123456/some-bff/123456" + * в строку вида: + * "/api/v2/something/{id}/some-bff/123456" + * и возвращает кортеж с этой строкой и вырезанным числом в случае если оно найдено. + * @param url Url. + * @return Кортеж со строкой и результатом поиска числа. + */ +export function hideFirstId(url: string): [string, number | undefined] { + const found = /\d{2,}/.exec(url); + + return found ? [url.replace(found[0], '{id}'), Number(found[0])] : [url, undefined]; +} diff --git a/src/preset/node/handler/providers/index.tsx b/src/preset/node/handler/providers/index.tsx index 414456e..fb5ab8d 100644 --- a/src/preset/node/handler/providers/index.tsx +++ b/src/preset/node/handler/providers/index.tsx @@ -28,7 +28,7 @@ import type { ConventionalJson } from '../../../isomorphic/types'; import { Fragment } from 'react'; import { HelmetContext, RegularHelmet, getPageResponseFormat } from '../utils'; import { renderToString } from 'react-dom/server'; -import { getFetchTracing } from '../../../server/utils'; +import { getFetchTracing } from '../../../server/utils/get-fetch-tracing'; /** * Провайдер главной функции обработчика входящего http-запроса. diff --git a/src/preset/node/node/utils/http-client/__test__/index.test.ts b/src/preset/node/node/utils/http-client/__test__/index.test.ts index c63775e..e404ccc 100644 --- a/src/preset/node/node/utils/http-client/__test__/index.test.ts +++ b/src/preset/node/node/utils/http-client/__test__/index.test.ts @@ -1,9 +1,9 @@ import { Context } from '@opentelemetry/api'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { Span, Tracer } from '@opentelemetry/sdk-trace-base'; -import { axiosTracingMiddleware, getRequestInfo, hideFirstId, getForwardedHeaders } from '..'; +import { axiosTracingMiddleware, getRequestInfo, getForwardedHeaders } from '..'; import { BaseConfig } from '../../../../../../config'; -import { Request } from 'express'; +import express from 'express'; describe('axiosTracingMiddleware', () => { it('should handle success response', async () => { @@ -144,29 +144,6 @@ describe('getRequestInfo', () => { }); }); -describe('hideFirstId', () => { - const cases = [ - { - input: '/api/v2/something/123456/some-bff/123456', - output: ['/api/v2/something/{id}/some-bff/123456', 123456], - }, - { - input: '/api/v2/something/222/some-bff/333', - output: ['/api/v2/something/{id}/some-bff/333', 222], - }, - { - input: '/api/v2/something/45320/some-bff', - output: ['/api/v2/something/{id}/some-bff', 45320], - }, - ]; - - it('should replace first id only', () => { - cases.forEach(({ input, output }) => { - expect(hideFirstId(input)).toEqual(output); - }); - }); -}); - describe('getRequestHeaders', () => { it('should return headers', () => { const config: BaseConfig = { @@ -175,7 +152,7 @@ describe('getRequestHeaders', () => { env: 'test', }; - const request: Request = { + const request: express.Request = { socket: { remoteAddress: '127.0.0.1', }, @@ -210,7 +187,7 @@ describe('getRequestHeaders', () => { env: 'test', }; - const request: Request = { + const request: express.Request = { socket: { remoteAddress: undefined, }, diff --git a/src/preset/node/node/utils/http-client/index.ts b/src/preset/node/node/utils/http-client/index.ts index 0490b91..01f6471 100644 --- a/src/preset/node/node/utils/http-client/index.ts +++ b/src/preset/node/node/utils/http-client/index.ts @@ -6,6 +6,7 @@ import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { BaseConfig } from '../../../../../config'; import { getClientIp } from '../http-server'; import { displayUrl } from '../../../../isomorphic/utils'; +import { hideFirstId } from '../../../../isomorphic/utils/hide-first-id'; /** * Возвращает новый middleware для трассировки исходящих запросов. @@ -80,21 +81,6 @@ export function getRequestInfo( }; } -/** - * Преобразует строку вида: - * "/api/v2/something/123456/some-bff/123456" - * в строку вида: - * "/api/v2/something/{id}/some-bff/123456" - * и возвращает кортеж с этой строкой и вырезанным числом в случае если оно найдено. - * @param url Url. - * @return Кортеж со строкой и результатом поиска числа. - */ -export function hideFirstId(url: string): [string, number | undefined] { - const found = /\d{2,}/.exec(url); - - return found ? [url.replace(found[0], '{id}'), Number(found[0])] : [url, undefined]; -} - /** * Формирует заголовки для исходящих запросов с сервера по соглашению. * @param config Конфиг. diff --git a/src/preset/server/index.ts b/src/preset/server/index.ts index 0b1b664..772ce33 100644 --- a/src/preset/server/index.ts +++ b/src/preset/server/index.ts @@ -1,2 +1,5 @@ export type { ServerHandler, ServerMiddleware, ServerHandlerContext } from './types'; -export { getClientIp, getForwardedHeaders, getPageResponseFormat, healthCheck } from './utils'; +export { getClientIp } from './utils/get-client-ip'; +export { getForwardedHeaders } from './utils/get-forwarded-headers'; +export { getHealthCheck } from './utils/get-health-check'; +export { getPageResponseFormat } from './utils/get-page-response-format'; diff --git a/src/preset/server/utils/__test__/apply-server-middleware.test.ts b/src/preset/server/utils/__test__/apply-server-middleware.test.ts new file mode 100644 index 0000000..2b2c3d5 --- /dev/null +++ b/src/preset/server/utils/__test__/apply-server-middleware.test.ts @@ -0,0 +1,49 @@ +import { ServerMiddleware } from '../../types'; +import { applyServerMiddleware } from '../apply-server-middleware'; + +describe('applyServerMiddleware', () => { + it('should compose middleware', async () => { + const log: string[] = []; + + const foo: ServerMiddleware = async (request, next) => { + log.push(''); + const result = await next(request); + log.push(''); + return result; + }; + + const bar: ServerMiddleware = async (request, next) => { + log.push(''); + const result = await next(request); + log.push(''); + return result; + }; + + const baz: ServerMiddleware = async (request, next) => { + log.push(''); + const result = await next(request); + log.push(''); + return result; + }; + + const enhancer = applyServerMiddleware(foo, bar, baz); + + const handler = enhancer(() => { + log.push(''); + return new Response('Test'); + }); + + await handler(new Request('http://test.com'), { events: new EventTarget() }); + + expect(log).toEqual([ + // expected log + '', + '', + '', + '', + '', + '', + '', + ]); + }); +}); diff --git a/src/preset/server/utils/__test__/get-client-ip.test.ts b/src/preset/server/utils/__test__/get-client-ip.test.ts new file mode 100644 index 0000000..3c1d01f --- /dev/null +++ b/src/preset/server/utils/__test__/get-client-ip.test.ts @@ -0,0 +1,23 @@ +import { getClientIp } from '../get-client-ip'; + +describe('getClientIp', () => { + it('should handle "x-client-ip" header', () => { + const request = new Request('http://test.com', { + headers: { + 'x-client-ip': '127.1.2.3', + }, + }); + + expect(getClientIp(request)).toBe('127.1.2.3'); + }); + + it('should handle "x-forwarded-for" header', () => { + const request = new Request('http://test.com', { + headers: { + 'x-forwarded-for': '111.222.111.222', + }, + }); + + expect(getClientIp(request)).toBe('111.222.111.222'); + }); +}); diff --git a/src/preset/server/utils/__test__/get-fetch-tracing.test.ts b/src/preset/server/utils/__test__/get-fetch-tracing.test.ts new file mode 100644 index 0000000..04979c7 --- /dev/null +++ b/src/preset/server/utils/__test__/get-fetch-tracing.test.ts @@ -0,0 +1,41 @@ +import { getFetchTracing } from '../get-fetch-tracing'; + +describe('getFetchTracing', () => { + it('should trace fetch stages', async () => { + const tracer: any = { + startSpan: jest.fn(() => ({ + setAttributes: jest.fn(), + setStatus: jest.fn(), + end: jest.fn(), + })), + }; + const context: any = {}; + const middleware = getFetchTracing(tracer, context); + + const request = new Request('http://test.com/api/v2/product/1005002'); + + expect( + await Promise.resolve( + middleware(request, () => Promise.resolve(new Response('OK'))), + ).catch(() => 'catch!'), + ).not.toBe('catch!'); + }); + + it('should handle error', async () => { + const tracer: any = { + startSpan: jest.fn(() => ({ + setAttributes: jest.fn(), + setStatus: jest.fn(), + end: jest.fn(), + })), + }; + const context: any = {}; + const middleware = getFetchTracing(tracer, context); + + const request = new Request('http://test.com/api/v2/product/1005002'); + + expect( + await Promise.resolve(middleware(request, () => Promise.reject('FAKE ERROR'))).catch(e => e), + ).toBe('FAKE ERROR'); + }); +}); diff --git a/src/preset/server/utils/__test__/get-forwarded-headers.test.ts b/src/preset/server/utils/__test__/get-forwarded-headers.test.ts new file mode 100644 index 0000000..4282764 --- /dev/null +++ b/src/preset/server/utils/__test__/get-forwarded-headers.test.ts @@ -0,0 +1,38 @@ +import { getForwardedHeaders } from '../get-forwarded-headers'; + +describe('getForwardedHeaders', () => { + it('should contain headers by convention', () => { + const config = { appName: 'app_name', appVersion: 'version', env: 'env' }; + const request = new Request('http://test.com', { + headers: { + 'x-client-ip': '127.0.0.89', + 'simaland-foo': 'hello', + 'simaland-bar': 'world', + 'simaland-params': JSON.stringify({ testParam: 123 }), + }, + }); + const result = getForwardedHeaders(config, request); + + expect(result.get('user-agent')).toBe('simaland-app_name/version'); + expect(result.get('x-client-ip')).toBe('127.0.0.89'); + expect(result.get('simaland-foo')).toBe('hello'); + expect(result.get('simaland-bar')).toBe('world'); + expect(result.get('simaland-params')).toBe(null); + }); + + it('should not contain x-client-ip when client ip is not defined', () => { + const config = { appName: 'app_name', appVersion: 'version', env: 'env' }; + const request = new Request('http://test.com', { + headers: { + 'simaland-foo': 'hello', + 'simaland-bar': 'world', + }, + }); + const result = getForwardedHeaders(config, request); + + expect(result.get('user-agent')).toBe('simaland-app_name/version'); + expect(result.get('x-client-ip')).toBe(null); + expect(result.get('simaland-foo')).toBe('hello'); + expect(result.get('simaland-bar')).toBe('world'); + }); +}); diff --git a/src/preset/server/utils/__test__/get-health-check.test.ts b/src/preset/server/utils/__test__/get-health-check.test.ts new file mode 100644 index 0000000..c8ff793 --- /dev/null +++ b/src/preset/server/utils/__test__/get-health-check.test.ts @@ -0,0 +1,26 @@ +import { getHealthCheck } from '../get-health-check'; + +describe('healthCheck', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('handler should return response', async () => { + const handler = getHealthCheck(); + const request = new Request(''); + + const res1 = await handler(request); + expect(res1.headers.get('content-type')).toBe('application/json'); + expect(await res1.json()).toEqual({ uptime: 0 }); + + jest.advanceTimersByTime(1000); + + const res2 = await handler(request); + expect(res2.headers.get('content-type')).toBe('application/json'); + expect(await res2.json()).toEqual({ uptime: 1000 }); + }); +}); diff --git a/src/preset/server/utils/__test__/get-page-response-format.test.ts b/src/preset/server/utils/__test__/get-page-response-format.test.ts new file mode 100644 index 0000000..ba3e47e --- /dev/null +++ b/src/preset/server/utils/__test__/get-page-response-format.test.ts @@ -0,0 +1,27 @@ +import { getPageResponseFormat } from '../get-page-response-format'; + +describe('getPageResponseFormat', () => { + it('should return json', () => { + const request = new Request('http://test.com', { + headers: { accept: 'application/json' }, + }); + + expect(getPageResponseFormat(request)).toBe('json'); + }); + + it('should return html when no accept', () => { + const request = new Request('http://test.com', { + headers: { accept: '' }, + }); + + expect(getPageResponseFormat(request)).toBe('html'); + }); + + it('should return html', () => { + const request = new Request('http://test.com', { + headers: { accept: 'text/html' }, + }); + + expect(getPageResponseFormat(request)).toBe('html'); + }); +}); diff --git a/src/preset/server/utils/__test__/get-serve-error-logging.test.ts b/src/preset/server/utils/__test__/get-serve-error-logging.test.ts new file mode 100644 index 0000000..ab23cbe --- /dev/null +++ b/src/preset/server/utils/__test__/get-serve-error-logging.test.ts @@ -0,0 +1,35 @@ +import { DetailedError, createLogger } from '../../../../log'; +import { getServeErrorLogging } from '../get-serve-error-logging'; + +describe('getServeErrorLogging', () => { + it('should log error', async () => { + const spy = jest.fn(); + const logger = createLogger(); + + logger.subscribe(spy); + + const request = new Request('http://test.ru'); + const middleware = getServeErrorLogging(logger); + + await Promise.resolve(middleware(request, () => Promise.reject('FAKE ERROR'))).catch(() => {}); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'error', + data: new DetailedError('FAKE ERROR', { + level: 'error', + context: [ + { + key: 'Incoming request details', + data: { + url: 'http://test.ru', + method: 'GET', + headers: {}, + params: {}, + }, + }, + ], + }), + }); + }); +}); diff --git a/src/preset/server/utils/__test__/get-serve-logging.test.ts b/src/preset/server/utils/__test__/get-serve-logging.test.ts new file mode 100644 index 0000000..17d0913 --- /dev/null +++ b/src/preset/server/utils/__test__/get-serve-logging.test.ts @@ -0,0 +1,53 @@ +import { createLogger } from '../../../../log'; +import { getServeLogging } from '../get-serve-logging'; + +describe('getServeLogging', () => { + it('should log request and response', async () => { + const spy = jest.fn(); + const logger = createLogger(); + + logger.subscribe(spy); + + const middleware = getServeLogging(logger); + + const signal = new EventTarget(); + + const next = () => + new Promise(resolve => { + signal.addEventListener( + 'response', + () => { + resolve(new Response('OK', { status: 200 })); + }, + { once: true }, + ); + }); + + middleware(new Request('http://test.ru'), next); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toEqual({ + type: 'info', + data: { + type: 'http.request[incoming]', + route: 'http://test.ru', + method: 'GET', + remote_ip: null, + }, + }); + + signal.dispatchEvent(new Event('response')); + await Promise.resolve(); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1][0]).toEqual({ + type: 'info', + data: { + type: 'http.response[outgoing]', + route: 'http://test.ru', + method: 'GET', + status: 200, + remote_ip: null, + latency: expect.any(Number), + }, + }); + }); +}); diff --git a/src/preset/server/utils/__test__/get-serve-measuring.test.ts b/src/preset/server/utils/__test__/get-serve-measuring.test.ts new file mode 100644 index 0000000..abcae0f --- /dev/null +++ b/src/preset/server/utils/__test__/get-serve-measuring.test.ts @@ -0,0 +1,30 @@ +import { BaseConfig } from '../../../../config'; +import { RESPONSE_EVENT_TYPE } from '../../../isomorphic/constants'; +import { getServeMeasuring } from '../get-serve-measuring'; + +describe('getServeMeasuring', () => { + it('should collect metrics', async () => { + const config: BaseConfig = { + env: 'test', + appName: 'testApp', + appVersion: 'testVer', + }; + + const middleware = getServeMeasuring(config); + const events = new EventTarget(); + + expect(async () => { + await middleware( + new Request('http://test.com'), + () => { + events.dispatchEvent(new Event(RESPONSE_EVENT_TYPE.renderStart)); + events.dispatchEvent(new Event(RESPONSE_EVENT_TYPE.renderFinish)); + return Promise.resolve(new Response('OK')); + }, + { + events, + }, + ); + }).not.toThrow(); + }); +}); diff --git a/src/preset/server/utils/__test__/index.test.ts b/src/preset/server/utils/__test__/index.test.ts deleted file mode 100644 index 9bb59d0..0000000 --- a/src/preset/server/utils/__test__/index.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { - healthCheck, - getPageResponseFormat, - getForwardedHeaders, - getServeLogging, - getServeErrorLogging, - getServeMeasuring, - getFetchTracing, - applyServerMiddleware, -} from '..'; -import { BaseConfig } from '../../../../config'; -import { DetailedError, createLogger } from '../../../../log'; -import { RESPONSE_EVENT_TYPE } from '../../../isomorphic/constants'; -import { ServerMiddleware } from '../../types'; - -describe('getPageResponseFormat', () => { - it('should return json', () => { - const request = new Request('http://test.com', { - headers: { accept: 'application/json' }, - }); - - expect(getPageResponseFormat(request)).toBe('json'); - }); - - it('should return html when no accept', () => { - const request = new Request('http://test.com', { - headers: { accept: '' }, - }); - - expect(getPageResponseFormat(request)).toBe('html'); - }); - - it('should return html', () => { - const request = new Request('http://test.com', { - headers: { accept: 'text/html' }, - }); - - expect(getPageResponseFormat(request)).toBe('html'); - }); -}); - -describe('getForwardedHeaders', () => { - it('should contain headers by convention', () => { - const config = { appName: 'app_name', appVersion: 'version', env: 'env' }; - const request = new Request('http://test.com', { - headers: { - 'x-client-ip': '127.0.0.89', - 'simaland-foo': 'hello', - 'simaland-bar': 'world', - 'simaland-params': JSON.stringify({ testParam: 123 }), - }, - }); - const result = getForwardedHeaders(config, request); - - expect(result.get('user-agent')).toBe('simaland-app_name/version'); - expect(result.get('x-client-ip')).toBe('127.0.0.89'); - expect(result.get('simaland-foo')).toBe('hello'); - expect(result.get('simaland-bar')).toBe('world'); - expect(result.get('simaland-params')).toBe(null); - }); - - it('should not contain x-client-ip when client ip is not defined', () => { - const config = { appName: 'app_name', appVersion: 'version', env: 'env' }; - const request = new Request('http://test.com', { - headers: { - 'simaland-foo': 'hello', - 'simaland-bar': 'world', - }, - }); - const result = getForwardedHeaders(config, request); - - expect(result.get('user-agent')).toBe('simaland-app_name/version'); - expect(result.get('x-client-ip')).toBe(null); - expect(result.get('simaland-foo')).toBe('hello'); - expect(result.get('simaland-bar')).toBe('world'); - }); -}); - -describe('healthCheck', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - it('handler should return response', async () => { - const handler = healthCheck(); - const request = new Request(''); - - const res1 = await handler(request); - expect(res1.headers.get('content-type')).toBe('application/json'); - expect(await res1.json()).toEqual({ uptime: 0 }); - - jest.advanceTimersByTime(1000); - - const res2 = await handler(request); - expect(res2.headers.get('content-type')).toBe('application/json'); - expect(await res2.json()).toEqual({ uptime: 1000 }); - }); -}); - -describe('getServeLogging', () => { - it('should log request and response', async () => { - const spy = jest.fn(); - const logger = createLogger(); - - logger.subscribe(spy); - - const middleware = getServeLogging(logger); - - const signal = new EventTarget(); - - const next = () => - new Promise(resolve => { - signal.addEventListener( - 'response', - () => { - resolve(new Response('OK', { status: 200 })); - }, - { once: true }, - ); - }); - - middleware(new Request('http://test.ru'), next); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0][0]).toEqual({ - type: 'info', - data: { - type: 'http.request[incoming]', - route: 'http://test.ru', - method: 'GET', - remote_ip: null, - }, - }); - - signal.dispatchEvent(new Event('response')); - await Promise.resolve(); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy.mock.calls[1][0]).toEqual({ - type: 'info', - data: { - type: 'http.response[outgoing]', - route: 'http://test.ru', - method: 'GET', - status: 200, - remote_ip: null, - latency: expect.any(Number), - }, - }); - }); -}); - -describe('getServeErrorLogging', () => { - it('should log error', async () => { - const spy = jest.fn(); - const logger = createLogger(); - - logger.subscribe(spy); - - const request = new Request('http://test.ru'); - const middleware = getServeErrorLogging(logger); - - await Promise.resolve(middleware(request, () => Promise.reject('FAKE ERROR'))).catch(() => {}); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0][0]).toEqual({ - type: 'error', - data: new DetailedError('FAKE ERROR', { - level: 'error', - context: [ - { - key: 'Incoming request details', - data: { - url: 'http://test.ru', - method: 'GET', - headers: {}, - params: {}, - }, - }, - ], - }), - }); - }); -}); - -describe('getServeMeasuring', () => { - it('should collect metrics', async () => { - const config: BaseConfig = { env: 'test', appName: 'testApp', appVersion: 'testVer' }; - const middleware = getServeMeasuring(config); - const events = new EventTarget(); - - expect(async () => { - await middleware( - new Request('http://test.com'), - () => { - events.dispatchEvent(new Event(RESPONSE_EVENT_TYPE.renderStart)); - events.dispatchEvent(new Event(RESPONSE_EVENT_TYPE.renderFinish)); - return Promise.resolve(new Response('OK')); - }, - { - events, - }, - ); - }).not.toThrow(); - }); -}); - -describe('getFetchTracing', () => { - it('should trace fetch stages', async () => { - const tracer: any = { - startSpan: jest.fn(() => ({ - setAttributes: jest.fn(), - setStatus: jest.fn(), - end: jest.fn(), - })), - }; - const context: any = {}; - const middleware = getFetchTracing(tracer, context); - - const request = new Request('http://test.com/api/v2/product/1005002'); - - expect( - await Promise.resolve( - middleware(request, () => Promise.resolve(new Response('OK'))), - ).catch(() => 'catch!'), - ).not.toBe('catch!'); - }); - - it('should handle error', async () => { - const tracer: any = { - startSpan: jest.fn(() => ({ - setAttributes: jest.fn(), - setStatus: jest.fn(), - end: jest.fn(), - })), - }; - const context: any = {}; - const middleware = getFetchTracing(tracer, context); - - const request = new Request('http://test.com/api/v2/product/1005002'); - - expect( - await Promise.resolve(middleware(request, () => Promise.reject('FAKE ERROR'))).catch(e => e), - ).toBe('FAKE ERROR'); - }); -}); - -describe('applyServerMiddleware', () => { - it('should compose middleware', async () => { - const log: string[] = []; - - const foo: ServerMiddleware = async (request, next) => { - log.push(''); - const result = await next(request); - log.push(''); - return result; - }; - - const bar: ServerMiddleware = async (request, next) => { - log.push(''); - const result = await next(request); - log.push(''); - return result; - }; - - const baz: ServerMiddleware = async (request, next) => { - log.push(''); - const result = await next(request); - log.push(''); - return result; - }; - - const enhancer = applyServerMiddleware(foo, bar, baz); - - const handler = enhancer(() => { - log.push(''); - return new Response('Test'); - }); - - await handler(new Request('http://test.com'), { events: new EventTarget() }); - - expect(log).toEqual([ - // expected log - '', - '', - '', - '', - '', - '', - '', - ]); - }); -}); diff --git a/src/preset/server/utils/apply-server-middleware.ts b/src/preset/server/utils/apply-server-middleware.ts new file mode 100644 index 0000000..adcc7ba --- /dev/null +++ b/src/preset/server/utils/apply-server-middleware.ts @@ -0,0 +1,19 @@ +import type { ServerEnhancer, ServerMiddleware } from '../types'; + +/** + * @inheritdoc + * @internal + */ +export function applyServerMiddleware(...list: ServerMiddleware[]): ServerEnhancer { + return handler => { + let result = handler; + + for (const item of list.reverse()) { + const next = result; + result = async (request, context) => + item(request, (req, ctx) => next(req, ctx ?? context), context); + } + + return result; + }; +} diff --git a/src/preset/server/utils/get-client-ip.ts b/src/preset/server/utils/get-client-ip.ts new file mode 100644 index 0000000..4b82428 --- /dev/null +++ b/src/preset/server/utils/get-client-ip.ts @@ -0,0 +1,10 @@ +/** + * Вернет строку с IP на основе заголовков запроса. + * @param request Запрос. + * @return IP или null. + */ +export function getClientIp(request: Request): string | null { + const headerValue = request.headers.get('x-client-ip') || request.headers.get('x-forwarded-for'); + + return headerValue; +} diff --git a/src/preset/server/utils/get-fetch-tracing.ts b/src/preset/server/utils/get-fetch-tracing.ts new file mode 100644 index 0000000..d6c0fc5 --- /dev/null +++ b/src/preset/server/utils/get-fetch-tracing.ts @@ -0,0 +1,45 @@ +import type { Middleware } from '../../../http'; +import { SpanStatusCode, type Context, type Tracer } from '@opentelemetry/api'; +import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; +import { hideFirstId } from '../../isomorphic/utils/hide-first-id'; + +/** + * Вернет новый промежуточный слой трассировки для fetch. + * @param tracer Трассировщик. + * @param rootContext Контекст. + * @return Промежуточный слой трассировки. + */ +export function getFetchTracing(tracer: Tracer, rootContext: Context): Middleware { + return async (request, next) => { + const [url, foundId] = hideFirstId(new URL(request.url).pathname); + const span = tracer.startSpan(`HTTP ${request.method} ${url}`, undefined, rootContext); + + span.setAttributes({ + [SemanticAttributes.HTTP_URL]: url, + [SemanticAttributes.HTTP_METHOD]: request.method, + 'request.params': JSON.stringify(new URL(request.url).searchParams), + 'request.headers': JSON.stringify(request.headers), + + // если нашли id - добавляем в теги + ...(foundId && { 'request.id': foundId }), + }); + + try { + const response = await next(request); + + span.end(); + + return response; + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: 'HTTP Request failed', + }); + + span.end(); + + // не прячем ошибку + throw error; + } + }; +} diff --git a/src/preset/server/utils/get-forwarded-headers.ts b/src/preset/server/utils/get-forwarded-headers.ts new file mode 100644 index 0000000..c743258 --- /dev/null +++ b/src/preset/server/utils/get-forwarded-headers.ts @@ -0,0 +1,34 @@ +import type { BaseConfig } from '../../../config'; +import { getClientIp } from './get-client-ip'; + +/** + * Вернет заголовки, которые должны содержаться в исходящих http-запросах при обработке входящего http-запроса. + * @param config Конфигурация. + * @param request Входящий запрос. + * @return Заголовки. + */ +export function getForwardedHeaders(config: BaseConfig, request: Request): Headers { + const result = new Headers(); + + // user agent + result.set('User-Agent', `simaland-${config.appName}/${config.appVersion}`); + + // client ip + const clientIp = getClientIp(request); + + if (clientIp) { + result.set('X-Client-Ip', clientIp); + } + + // service headers + request.headers.forEach((headerValue, headerName) => { + if ( + headerName.toLowerCase().startsWith('simaland-') && + headerName.toLowerCase() !== 'simaland-params' + ) { + result.set(headerName, headerValue); + } + }); + + return result; +} diff --git a/src/preset/server/utils/get-health-check.ts b/src/preset/server/utils/get-health-check.ts new file mode 100644 index 0000000..2982164 --- /dev/null +++ b/src/preset/server/utils/get-health-check.ts @@ -0,0 +1,17 @@ +import type { Handler } from '../../../http'; + +/** + * Возвращает новый обработчик входящих запросов. + * Обработчик возвращает ответ в формате JSON, тело - объект с полем "uptime" типа number. + * @return Обработчик. + */ +export function getHealthCheck(): Handler { + const startTime = Date.now(); + + return () => + new Response(JSON.stringify({ uptime: Date.now() - startTime }), { + headers: { + 'content-type': 'application/json', + }, + }); +} diff --git a/src/preset/server/utils/get-page-response-format.ts b/src/preset/server/utils/get-page-response-format.ts new file mode 100644 index 0000000..732e1cf --- /dev/null +++ b/src/preset/server/utils/get-page-response-format.ts @@ -0,0 +1,16 @@ +/** + * Определяет формат ответа для страницы (html-верстки). + * Вернет "json" - если заголовок запроса "accept" содержит "application/json". + * Вернет "html" во всех остальных случаях. + * @param request Запрос. + * @return Формат. + */ +export function getPageResponseFormat(request: Request): 'html' | 'json' { + let result: 'html' | 'json' = 'html'; + + if ((request.headers.get('accept') || '').toLowerCase().includes('application/json')) { + result = 'json'; + } + + return result; +} diff --git a/src/preset/server/utils/get-serve-error-logging.ts b/src/preset/server/utils/get-serve-error-logging.ts new file mode 100644 index 0000000..ffa53a5 --- /dev/null +++ b/src/preset/server/utils/get-serve-error-logging.ts @@ -0,0 +1,31 @@ +import { FetchUtil, log, type Middleware } from '../../../http'; +import { DetailedError, type Logger } from '../../../log'; + +/** + * Возвращает новый промежуточный слой для логирования ошибки при обработке входящего http-запроса. + * @param logger Logger. + * @return Промежуточный слой. + */ +export function getServeErrorLogging(logger: Logger): Middleware { + return log({ + onCatch: ({ request, error }) => { + logger.error( + new DetailedError(String(error), { + level: 'error', + context: [ + { + key: 'Incoming request details', + data: { + url: FetchUtil.withoutParams(request.url), + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + params: Object.fromEntries(new URL(request.url).searchParams.entries()), + // @todo data + }, + }, + ], + }), + ); + }, + }); +} diff --git a/src/preset/server/utils/get-serve-logging.ts b/src/preset/server/utils/get-serve-logging.ts new file mode 100644 index 0000000..8c97367 --- /dev/null +++ b/src/preset/server/utils/get-serve-logging.ts @@ -0,0 +1,38 @@ +import type { Middleware } from '../../../http'; +import type { Logger } from '../../../log'; +import { toMilliseconds } from '../../../utils'; +import { getClientIp } from './get-client-ip'; + +/** + * Возвращает новый промежуточный слой для логирования обработки входящего http-запроса. + * @param logger Logger. + * @return Промежуточный слой. + */ +export function getServeLogging(logger: Logger): Middleware { + return async (request, next) => { + const start = process.hrtime.bigint(); + const clientIp = getClientIp(request); + + logger.info({ + type: 'http.request[incoming]', + route: request.url, + method: request.method, + remote_ip: clientIp, + }); + + const response = await next(request); + + const finish = process.hrtime.bigint(); + + logger.info({ + type: 'http.response[outgoing]', + route: request.url, + method: request.method, + status: response.status, + remote_ip: clientIp, + latency: toMilliseconds(finish - start), + }); + + return response; + }; +} diff --git a/src/preset/server/utils/get-serve-measuring.ts b/src/preset/server/utils/get-serve-measuring.ts new file mode 100644 index 0000000..c1ccf9e --- /dev/null +++ b/src/preset/server/utils/get-serve-measuring.ts @@ -0,0 +1,93 @@ +import type { BaseConfig } from '../../../config'; +import type { ServerMiddleware } from '../types'; +import { RESPONSE_EVENT_TYPE } from '../../isomorphic/constants'; +import { toMilliseconds } from '../../../utils'; +import PromClient from 'prom-client'; + +/** + * Возвращает новый промежуточный слой метрки входящего http-запроса. + * @param config Конфигурация. + * @return Промежуточный слой. + */ +export function getServeMeasuring(config: BaseConfig): ServerMiddleware { + const ConventionalLabels = { + HTTP_RESPONSE: ['version', 'route', 'code', 'method'], + SSR: ['version', 'route', 'method'], + } as const; + + // @todo скорее всего стоит сделать обертки над классами из PromClient чтобы вызывать у них методы замера а они уже сами вычисляли лейблы и тд + const requestCount = new PromClient.Counter({ + name: 'http_request_count', + help: 'Incoming HTTP request count', + labelNames: ConventionalLabels.HTTP_RESPONSE, + }); + + const responseDuration = new PromClient.Histogram({ + name: 'http_response_duration_ms', + help: 'Duration of incoming HTTP requests in ms', + labelNames: ConventionalLabels.HTTP_RESPONSE, + buckets: [30, 100, 200, 500, 1000, 2500, 5000, 10000], + }); + + const renderDuration = new PromClient.Histogram({ + name: 'render_duration_ms', + help: 'Duration of SSR ms', + labelNames: ConventionalLabels.SSR, + buckets: [0.1, 15, 50, 100, 250, 500, 800, 1500], + }); + + /** @inheritdoc */ + const getResponseLabels = (req: Request, res: Response) => + ({ + version: config.appVersion, + route: req.url, + code: res.status, + method: req.method, + }) satisfies Record<(typeof ConventionalLabels.HTTP_RESPONSE)[number], string | number>; + + /** @inheritdoc */ + const getRenderLabels = (request: Request) => + ({ + version: config.appVersion, + method: request.method, + route: request.url, + }) satisfies Record<(typeof ConventionalLabels.SSR)[number], string | number>; + + return async (request, next, context) => { + const responseStart = process.hrtime.bigint(); + let renderStart = 0n; + + context.events.addEventListener( + RESPONSE_EVENT_TYPE.renderStart, + () => { + renderStart = process.hrtime.bigint(); + }, + { once: true }, + ); + + context.events.addEventListener( + RESPONSE_EVENT_TYPE.renderFinish, + () => { + const renderFinish = process.hrtime.bigint(); + + renderDuration.observe( + getRenderLabels(request), + toMilliseconds(renderFinish - renderStart), + ); + }, + { once: true }, + ); + + const response = await next(request); + const responseFinish = process.hrtime.bigint(); + + responseDuration.observe( + getResponseLabels(request, response), + toMilliseconds(responseFinish - responseStart), + ); + + requestCount.inc(getResponseLabels(request, response), 1); + + return response; + }; +} diff --git a/src/preset/server/utils/index.ts b/src/preset/server/utils/index.ts deleted file mode 100644 index 38fd48b..0000000 --- a/src/preset/server/utils/index.ts +++ /dev/null @@ -1,295 +0,0 @@ -import type { BaseConfig } from '../../../config'; -import { Handler, Middleware, FetchUtil, log } from '../../../http'; -import type { ServerEnhancer, ServerMiddleware } from '../types'; -import { SpanStatusCode, type Context, type Tracer } from '@opentelemetry/api'; -import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; -import { hideFirstId } from '../../node/node/utils/http-client'; -import { DetailedError, Logger } from '../../../log'; -import { toMilliseconds } from '../../../utils'; -import { RESPONSE_EVENT_TYPE } from '../../isomorphic/constants'; -import PromClient from 'prom-client'; - -/** - * Определяет формат ответа для страницы (html-верстки). - * Вернет "json" - если заголовок запроса "accept" содержит "application/json". - * Вернет "html" во всех остальных случаях. - * @param request Запрос. - * @return Формат. - */ -export function getPageResponseFormat(request: Request): 'html' | 'json' { - let result: 'html' | 'json' = 'html'; - - if ((request.headers.get('accept') || '').toLowerCase().includes('application/json')) { - result = 'json'; - } - - return result; -} - -/** - * Вернет заголовки, которые должны содержаться в исходящих http-запросах при обработке входящего http-запроса. - * @param config Конфигурация. - * @param request Входящий запрос. - * @return Заголовки. - */ -export function getForwardedHeaders(config: BaseConfig, request: Request): Headers { - const result = new Headers(); - - // user agent - result.set('User-Agent', `simaland-${config.appName}/${config.appVersion}`); - - // client ip - const clientIp = getClientIp(request); - - if (clientIp) { - result.set('X-Client-Ip', clientIp); - } - - // service headers - request.headers.forEach((headerValue, headerName) => { - if ( - headerName.toLowerCase().startsWith('simaland-') && - headerName.toLowerCase() !== 'simaland-params' - ) { - result.set(headerName, headerValue); - } - }); - - return result; -} - -/** - * Вернет строку с IP на основе заголовков запроса. - * @param request Запрос. - * @return IP или null. - */ -export function getClientIp(request: Request): string | null { - const headerValue = request.headers.get('x-client-ip') || request.headers.get('x-forwarded-for'); - - return headerValue; -} - -/** - * Возвращает новый обработчик входящих запросов. - * Обработчик возвращает ответ в формате JSON, тело - объект с полем "uptime" типа number. - * @return Обработчик. - */ -export function healthCheck(): Handler { - const startTime = Date.now(); - - return () => - new Response(JSON.stringify({ uptime: Date.now() - startTime }), { - headers: { - 'content-type': 'application/json', - }, - }); -} - -/** - * Возвращает новый промежуточный слой для логирования обработки входящего http-запроса. - * @param logger Logger. - * @return Промежуточный слой. - */ -export function getServeLogging(logger: Logger): Middleware { - return async (request, next) => { - const start = process.hrtime.bigint(); - - logger.info({ - type: 'http.request[incoming]', - route: request.url, - method: request.method, - remote_ip: getClientIp(request), - }); - - const response = await next(request); - - const finish = process.hrtime.bigint(); - - logger.info({ - type: 'http.response[outgoing]', - route: request.url, - method: request.method, - status: response.status, - remote_ip: getClientIp(request), - latency: toMilliseconds(finish - start), - }); - - return response; - }; -} - -/** - * Возвращает новый промежуточный слой для логирования ошибки при обработке входящего http-запроса. - * @param logger Logger. - * @return Промежуточный слой. - */ -export function getServeErrorLogging(logger: Logger) { - return log({ - onCatch: ({ request, error }) => { - logger.error( - new DetailedError(String(error), { - level: 'error', - context: [ - { - key: 'Incoming request details', - data: { - url: FetchUtil.withoutParams(request.url), - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - params: Object.fromEntries(new URL(request.url).searchParams.entries()), - // @todo data - }, - }, - ], - }), - ); - }, - }); -} - -/** - * Возвращает новый промежуточный слой метрки входящего http-запроса. - * @param config Конфигурация. - * @return Промежуточный слой. - */ -export function getServeMeasuring(config: BaseConfig): ServerMiddleware { - const ConventionalLabels = { - HTTP_RESPONSE: ['version', 'route', 'code', 'method'], - SSR: ['version', 'route', 'method'], - } as const; - - // @todo скорее всего стоит сделать обертки над классами из PromClient чтобы вызывать у них методы замера а они уже сами вычисляли лейблы и тд - const requestCount = new PromClient.Counter({ - name: 'http_request_count', - help: 'Incoming HTTP request count', - labelNames: ConventionalLabels.HTTP_RESPONSE, - }); - - const responseDuration = new PromClient.Histogram({ - name: 'http_response_duration_ms', - help: 'Duration of incoming HTTP requests in ms', - labelNames: ConventionalLabels.HTTP_RESPONSE, - buckets: [30, 100, 200, 500, 1000, 2500, 5000, 10000], - }); - - const renderDuration = new PromClient.Histogram({ - name: 'render_duration_ms', - help: 'Duration of SSR ms', - labelNames: ConventionalLabels.SSR, - buckets: [0.1, 15, 50, 100, 250, 500, 800, 1500], - }); - - /** @inheritdoc */ - const getResponseLabels = (req: Request, res: Response) => - ({ - version: config.appVersion, - route: req.url, - code: res.status, - method: req.method, - }) satisfies Record<(typeof ConventionalLabels.HTTP_RESPONSE)[number], string | number>; - - /** @inheritdoc */ - const getRenderLabels = (request: Request) => - ({ - version: config.appVersion, - method: request.method, - route: request.url, - }) satisfies Record<(typeof ConventionalLabels.SSR)[number], string | number>; - - return async (request, next, context) => { - const responseStart = process.hrtime.bigint(); - let renderStart = 0n; - - context.events.addEventListener( - RESPONSE_EVENT_TYPE.renderStart, - () => { - renderStart = process.hrtime.bigint(); - }, - { once: true }, - ); - - context.events.addEventListener( - RESPONSE_EVENT_TYPE.renderFinish, - () => { - const renderFinish = process.hrtime.bigint(); - - renderDuration.observe( - getRenderLabels(request), - toMilliseconds(renderFinish - renderStart), - ); - }, - { once: true }, - ); - - const response = await next(request); - const responseFinish = process.hrtime.bigint(); - - responseDuration.observe( - getResponseLabels(request, response), - toMilliseconds(responseFinish - responseStart), - ); - - requestCount.inc(getResponseLabels(request, response), 1); - - return response; - }; -} - -/** - * Вернет новый промежуточный слой трассировки для fetch. - * @param tracer Трассировщик. - * @param rootContext Контекст. - * @return Промежуточный слой трассировки. - */ -export function getFetchTracing(tracer: Tracer, rootContext: Context): Middleware { - return async (request, next) => { - const [url, foundId] = hideFirstId(new URL(request.url).pathname); - const span = tracer.startSpan(`HTTP ${request.method} ${url}`, undefined, rootContext); - - span.setAttributes({ - [SemanticAttributes.HTTP_URL]: url, - [SemanticAttributes.HTTP_METHOD]: request.method, - 'request.params': JSON.stringify(new URL(request.url).searchParams), - 'request.headers': JSON.stringify(request.headers), - - // если нашли id - добавляем в теги - ...(foundId && { 'request.id': foundId }), - }); - - try { - const response = await next(request); - - span.end(); - - return response; - } catch (error) { - span.setStatus({ - code: SpanStatusCode.ERROR, - message: 'HTTP Request failed', - }); - - span.end(); - - // не прячем ошибку - throw error; - } - }; -} - -/** - * @inheritdoc - * @internal - */ -export function applyServerMiddleware(...list: ServerMiddleware[]): ServerEnhancer { - return handler => { - let result = handler; - - for (const item of list.reverse()) { - const next = result; - result = async (request, context) => - item(request, (req, ctx) => next(req, ctx ?? context), context); - } - - return result; - }; -}