diff --git a/examples/bun/.env.development b/examples/bun/.env.development index b35266c..70615c1 100644 --- a/examples/bun/.env.development +++ b/examples/bun/.env.development @@ -8,4 +8,5 @@ SENTRY_RELEASE='example-release' SENTRY_ENVIRONMENT='example-environment' # misc -MAIN_HTTP_PORT='8080' +HTTP_PORT_MAIN='8080' +HTTP_PORT_METRICS='8081' diff --git a/examples/bun/package.json b/examples/bun/package.json index 145d7aa..b36504e 100644 --- a/examples/bun/package.json +++ b/examples/bun/package.json @@ -4,7 +4,7 @@ "description": "Example of http server application on Bun", "scripts": { "preparing": "cd ../.. && npm run build && npm pack && cd examples/bun && npm i --no-save ../../sima-land-isomorph-0.0.0.tgz", - "dev": "NODE_ENV=development bun run ./src/index.ts", + "dev": "NODE_ENV=development bun --watch run ./src/index.ts", "type-check": "tsc -p . --noEmit" }, "author": "www.sima-land.ru team", diff --git a/examples/bun/src/app/index.ts b/examples/bun/src/app/index.ts index 9053840..f7ce3ee 100644 --- a/examples/bun/src/app/index.ts +++ b/examples/bun/src/app/index.ts @@ -3,6 +3,7 @@ import { PresetBun, HandlerProvider } from '@sima-land/isomorph/preset/bun'; import { TOKEN } from '../tokens'; import { PostsPageApp } from '../pages/posts'; import { AuthorsPageApp } from '../pages/authors'; +import { ServerHandler } from '@sima-land/isomorph/preset/server'; export function MainApp() { const app = createApplication(); @@ -11,7 +12,7 @@ export function MainApp() { app.preset( PresetBun(({ override }) => { // переопределяем провайдеры пресета - override(TOKEN.Lib.Http.Serve.routes, provideRoutes); + override(TOKEN.Lib.Http.Serve.routes, providePageRoutes); }), ); @@ -22,7 +23,8 @@ export function MainApp() { return app; } -function provideRoutes(resolve: Resolve) { +function providePageRoutes(resolve: Resolve): Array<[string, ServerHandler]> { + // определяем маршруты страниц return [ ['/', resolve(TOKEN.Pages.posts)], ['/posts', resolve(TOKEN.Pages.posts)], diff --git a/examples/bun/src/index.ts b/examples/bun/src/index.ts index 341be45..2fad16b 100644 --- a/examples/bun/src/index.ts +++ b/examples/bun/src/index.ts @@ -2,13 +2,20 @@ import { MainApp } from './app'; import { TOKEN } from './tokens'; MainApp().invoke( - [TOKEN.Lib.Config.source, TOKEN.Lib.logger, TOKEN.Lib.Http.serve], - (config, logger, serve) => { + [TOKEN.Lib.Config.source, TOKEN.Lib.logger, TOKEN.Lib.Http.serve, TOKEN.Lib.Metrics.httpHandler], + (config, logger, serve, serveMetrics) => { const server = Bun.serve({ - port: config.require('MAIN_HTTP_PORT'), + port: config.require('HTTP_PORT_MAIN'), fetch: serve, }); logger.info(`Server started on ${server.url}`); + + const metricsServer = Bun.serve({ + port: config.require('HTTP_PORT_METRICS'), + fetch: serveMetrics, + }); + + logger.info(`Metrics server started on ${metricsServer.url}`); }, ); diff --git a/examples/bun/src/pages/authors/index.tsx b/examples/bun/src/pages/authors/index.tsx index 6aedd15..f2e001a 100644 --- a/examples/bun/src/pages/authors/index.tsx +++ b/examples/bun/src/pages/authors/index.tsx @@ -1,5 +1,5 @@ import { createApplication, Resolve } from '@sima-land/isomorph/di'; -import { PresetHandler } from '@sima-land/isomorph/preset/bun'; +import { PresetBunHandler } from '@sima-land/isomorph/preset/bun-handler'; import { TOKEN } from '../../tokens'; import { Layout } from '../../components/Layout'; import { Nav } from '../../components/Nav'; @@ -10,7 +10,7 @@ export function AuthorsPageApp() { // используем пресет "PresetHandler" app.preset( - PresetHandler(({ override }) => { + PresetBunHandler(({ override }) => { // переопределяем провайдеры пресета override(TOKEN.Lib.Http.Handler.Page.render, provideRender); }), diff --git a/examples/bun/src/pages/posts/index.tsx b/examples/bun/src/pages/posts/index.tsx index 394f15d..1e0ccdb 100644 --- a/examples/bun/src/pages/posts/index.tsx +++ b/examples/bun/src/pages/posts/index.tsx @@ -1,5 +1,5 @@ import { createApplication, Resolve } from '@sima-land/isomorph/di'; -import { PresetHandler } from '@sima-land/isomorph/preset/bun'; +import { PresetBunHandler } from '@sima-land/isomorph/preset/bun-handler'; import { TOKEN } from '../../tokens'; import { Layout } from '../../components/Layout'; import { Nav } from '../../components/Nav'; @@ -10,7 +10,7 @@ export function PostsPageApp() { // используем пресет "PresetHandler" app.preset( - PresetHandler(({ override }) => { + PresetBunHandler(({ override }) => { // переопределяем провайдеры пресета override(TOKEN.Lib.Http.Handler.Page.render, provideRender); }), diff --git a/examples/bun/src/tokens.ts b/examples/bun/src/tokens.ts index 8ed898e..a95055e 100644 --- a/examples/bun/src/tokens.ts +++ b/examples/bun/src/tokens.ts @@ -2,7 +2,7 @@ import { createToken } from '@sima-land/isomorph/di'; import { KnownToken } from '@sima-land/isomorph/tokens'; // чтобы токены можно было использовать как в браузере так и на сервере импорты должны содержать только типы -import type { Handler } from '@sima-land/isomorph/http'; +import type { ServerHandler } from '@sima-land/isomorph/preset/server'; import type { AuthorApi } from './entities/author'; import type { PostApi } from './entities/post'; @@ -20,7 +20,7 @@ export const TOKEN = { }, }, Pages: { - posts: createToken('pages/posts'), - authors: createToken('pages/authors'), + posts: createToken('pages/posts'), + authors: createToken('pages/authors'), }, } as const; diff --git a/examples/node/webpack.config.js b/examples/node/webpack.config.js index 7a2b7e0..e2a6db9 100644 --- a/examples/node/webpack.config.js +++ b/examples/node/webpack.config.js @@ -46,6 +46,7 @@ module.exports = { new EnvPlugin(), new NodemonPlugin({ script: './dist/index.js', + exec: 'node --inspect', watch: path.resolve('./dist'), }), new MiniCssExtractPlugin({ diff --git a/package.json b/package.json index 5044de9..3bd4157 100644 --- a/package.json +++ b/package.json @@ -189,6 +189,12 @@ "import": "./dist/esm/preset/bun/index.js", "default": "./dist/esm/preset/bun/index.js" }, + "./preset/bun-handler": { + "types": "./dist/types/preset/bun-handler/index.d.ts", + "require": "./dist/cjs/preset/bun-handler/index.js", + "import": "./dist/esm/preset/bun-handler/index.js", + "default": "./dist/esm/preset/bun-handler/index.js" + }, "./utils": { "types": "./dist/types/utils/index.d.ts", "require": "./dist/cjs/utils/index.js", @@ -306,6 +312,9 @@ "preset/bun": [ "./dist/types/preset/bun/index.d.ts" ], + "preset/bun-handler": [ + "./dist/types/preset/bun-handler/index.d.ts" + ], "utils": [ "./dist/types/utils/index.d.ts" ], diff --git a/src/preset/bun-handler/providers/index.tsx b/src/preset/bun-handler/providers/index.tsx index f029379..a86f30e 100644 --- a/src/preset/bun-handler/providers/index.tsx +++ b/src/preset/bun-handler/providers/index.tsx @@ -11,12 +11,16 @@ import { cookie, createCookieStore, defaultHeaders, - log, LogHandler, LogHandlerFactory, } from '../../../http'; import { Fragment } from 'react'; -import { FetchLogging } from '../../isomorphic/utils'; +import { + FetchLogging, + getFetchErrorLogging, + getFetchExtraAborting, + getFetchLogging, +} from '../../isomorphic/utils'; import { getForwardedHeaders, getPageResponseFormat } from '../../server/utils'; import { PageAssets } from '../../isomorphic/types'; import { RESPONSE_EVENT_TYPE } from '../../isomorphic/constants'; @@ -177,40 +181,10 @@ export const HandlerProviders = { return [ // ВАЖНО: слой логирования ошибки ПЕРЕД остальными слоями чтобы не упустить ошибки выше - log(initData => { - if (typeof logHandler === 'function') { - return { - onCatch: data => logHandler(initData).onCatch?.(data), - }; - } - - return { - onCatch: data => logHandler.onCatch?.(data), - }; - }), + getFetchErrorLogging(logHandler), // обрывание по сигналу из обработчика входящего запроса и по сигналу из конфига исходящего запроса - (request, next) => { - const innerController = new AbortController(); - - request.signal?.addEventListener( - 'abort', - () => { - innerController.abort(); - }, - { once: true }, - ); - - abortController.signal.addEventListener( - 'abort', - () => { - innerController.abort(); - }, - { once: true }, - ); - - return next(new Request(request, { signal: innerController.signal })); - }, + getFetchExtraAborting(abortController), cookie(cookieStore), @@ -219,19 +193,7 @@ export const HandlerProviders = { // @todo metrics, tracing // ВАЖНО: слой логирования запроса и ответа ПОСЛЕ остальных слоев чтобы использовать актуальные данные - log(initData => { - if (typeof logHandler === 'function') { - return { - onRequest: data => logHandler(initData).onRequest?.(data), - onResponse: data => logHandler(initData).onResponse?.(data), - }; - } - - return { - onRequest: data => logHandler.onRequest?.(data), - onResponse: data => logHandler.onResponse?.(data), - }; - }), + getFetchLogging(logHandler), ]; }, diff --git a/src/preset/bun/providers/index.ts b/src/preset/bun/providers/index.ts index 1136c37..851ecdd 100644 --- a/src/preset/bun/providers/index.ts +++ b/src/preset/bun/providers/index.ts @@ -18,6 +18,7 @@ import { getServeMeasuring, } from '../../server/utils'; import PromClient from 'prom-client'; +import { statsHandler } from '../utils'; export const BunProviders = { configSource(): ConfigSource { @@ -88,6 +89,7 @@ export const BunProviders = { return [ // служебные маршруты (без промежуточных слоев) ['/healthcheck', healthCheck()], + ['/stats', statsHandler()], ]; }, diff --git a/src/preset/bun/utils/index.ts b/src/preset/bun/utils/index.ts index 0de426a..de758ee 100644 --- a/src/preset/bun/utils/index.ts +++ b/src/preset/bun/utils/index.ts @@ -1,4 +1,5 @@ /* eslint-disable require-jsdoc, jsdoc/require-jsdoc */ +import type { Handler } from '../../../http'; import type { ServerHandler } from '../../server/types'; import { KnownToken } from '../../../tokens'; import { CURRENT_APP, type Application, type Resolve, Provider } from '../../../di'; @@ -17,3 +18,24 @@ export function HandlerProvider(getApp: () => Application): Provider { + const jsc = await import( + /* webpackIgnore: true */ + `bun:jsc` + ); + + return jsc.heapStats(); + }; + + return async () => { + const stats = await getHeapStats(); + + return new Response(JSON.stringify(stats, null, 2), { + headers: { 'content-type': 'application/json' }, + }); + }; +} diff --git a/src/preset/isomorphic/utils/__test__/index.test.ts b/src/preset/isomorphic/utils/__test__/index.test.ts index b2997b0..159af01 100644 --- a/src/preset/isomorphic/utils/__test__/index.test.ts +++ b/src/preset/isomorphic/utils/__test__/index.test.ts @@ -14,6 +14,9 @@ import { HttpStatus, displayUrl, FetchLogging, + getFetchLogging, + getFetchErrorLogging, + getFetchExtraAborting, } from '..'; import { FetchUtil } from '../../../../http'; @@ -824,3 +827,125 @@ describe('FetchLogging', () => { }); }); }); + +describe('getFetchLogging', () => { + it('should log only request and response', async () => { + const requestSpy = jest.fn(); + const responseSpy = jest.fn(); + const catchSpy = jest.fn(); + + const middleware = getFetchLogging({ + onRequest: requestSpy, + onResponse: responseSpy, + onCatch: catchSpy, + }); + + await middleware(new Request('http://test.com'), () => + Promise.resolve(new Response('OK')), + ); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(responseSpy).toHaveBeenCalledTimes(1); + expect(catchSpy).toHaveBeenCalledTimes(0); + }); + + it('should handle function as handlerInit', async () => { + const requestSpy = jest.fn(); + const responseSpy = jest.fn(); + const catchSpy = jest.fn(); + + const middleware = getFetchLogging(() => ({ + onRequest: requestSpy, + onResponse: responseSpy, + onCatch: catchSpy, + })); + + await middleware(new Request('http://test.com'), () => + Promise.resolve(new Response('OK')), + ); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(responseSpy).toHaveBeenCalledTimes(1); + expect(catchSpy).toHaveBeenCalledTimes(0); + }); +}); + +describe('getFetchErrorLogging', () => { + it('should log only catch stage', async () => { + const requestSpy = jest.fn(); + const responseSpy = jest.fn(); + const catchSpy = jest.fn(); + + const middleware = getFetchErrorLogging({ + onRequest: requestSpy, + onResponse: responseSpy, + onCatch: catchSpy, + }); + + await Promise.resolve( + middleware(new Request('http://test.com'), () => Promise.reject('FAKE ERROR')), + ).catch(() => {}); + + expect(requestSpy).toHaveBeenCalledTimes(0); + expect(responseSpy).toHaveBeenCalledTimes(0); + expect(catchSpy).toHaveBeenCalledTimes(1); + }); + + it('should handle function as handlerInit', async () => { + const requestSpy = jest.fn(); + const responseSpy = jest.fn(); + const catchSpy = jest.fn(); + + const middleware = getFetchErrorLogging(() => ({ + onRequest: requestSpy, + onResponse: responseSpy, + onCatch: catchSpy, + })); + + await Promise.resolve( + middleware(new Request('http://test.com'), () => Promise.reject('FAKE ERROR')), + ).catch(() => {}); + + expect(requestSpy).toHaveBeenCalledTimes(0); + expect(responseSpy).toHaveBeenCalledTimes(0); + expect(catchSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('getFetchExtraAborting', () => { + it('should handle controller', async () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const middleware = getFetchExtraAborting(controller1); + + let request = new Request('http://stub.com'); + + await middleware(new Request('http://test.com', { signal: controller2.signal }), req => { + request = req; + return Promise.resolve(new Response('OK')); + }); + + expect(request.signal.aborted).toBe(false); + + controller1.abort(); + expect(request.signal.aborted).toBe(true); + }); + + it('should handle controller from request', async () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const middleware = getFetchExtraAborting(controller1); + + let request = new Request('http://stub.com'); + + await middleware(new Request('http://test.com', { signal: controller2.signal }), req => { + request = req; + return Promise.resolve(new Response('OK')); + }); + + expect(request.signal.aborted).toBe(false); + + controller2.abort(); + expect(request.signal.aborted).toBe(true); + }); +}); diff --git a/src/preset/isomorphic/utils/index.ts b/src/preset/isomorphic/utils/index.ts index 519492b..d1330c6 100644 --- a/src/preset/isomorphic/utils/index.ts +++ b/src/preset/isomorphic/utils/index.ts @@ -1,6 +1,6 @@ import { SeverityLevel } from '@sentry/browser'; import Axios, { AxiosRequestConfig } from 'axios'; -import type { Middleware } from 'middleware-axios'; +import type { Middleware as AxiosMiddleware } from 'middleware-axios'; import type { StrictMap } from '../types'; import type { ConfigSource } from '../../../config/types'; import { Logger, Breadcrumb, DetailedError } from '../../../log'; @@ -16,7 +16,16 @@ import { SagaInterruptInfo, SagaMiddlewareHandler, } from '../../../utils/redux-saga/types'; -import { DoneLogData, FailLogData, FetchUtil, LogData, LogHandler } from '../../../http'; +import { + DoneLogData, + FailLogData, + FetchUtil, + LogData, + LogHandler, + LogHandlerFactory, + Middleware, + log, +} from '../../../http'; /** Реализация пула хостов. */ export class HttpApiHostPool implements StrictMap { @@ -87,11 +96,77 @@ export function severityFromStatus(status: unknown): SeverityLevel { return result; } +/** + * Возвращает новый промежуточный слой логирования исходящего запроса и входящего ответа. + * @param handlerInit Обработчик. + * @return Промежуточный слой. + */ +export function getFetchLogging(handlerInit: LogHandler | LogHandlerFactory): Middleware { + const getHandler: LogHandlerFactory = + typeof handlerInit === 'function' ? handlerInit : () => handlerInit; + + return log({ + onRequest: data => { + getHandler(data).onRequest?.(data); + }, + onResponse: data => { + getHandler(data).onResponse?.(data); + }, + }); +} + +/** + * Возвращает новый промежуточный слой логирования ошибки исходящего запроса. + * @param handlerInit Обработчик. + * @return Промежуточный слой. + */ +export function getFetchErrorLogging(handlerInit: LogHandler | LogHandlerFactory): Middleware { + const getHandler: LogHandlerFactory = + typeof handlerInit === 'function' ? handlerInit : () => handlerInit; + + return log({ + onCatch: data => { + getHandler(data).onCatch?.(data); + }, + }); +} + +/** + * Возвращает новый промежуточный слой обрывания по заданному контроллеру. + * Учитывает передачу контроллера в запросе. + * @param controller Контроллер. + * @return Промежуточный слой. + */ +export function getFetchExtraAborting(controller: AbortController): Middleware { + return (request, next) => { + const innerController = new AbortController(); + + request.signal?.addEventListener( + 'abort', + () => { + innerController.abort(); + }, + { once: true }, + ); + + controller.signal.addEventListener( + 'abort', + () => { + innerController.abort(); + }, + { once: true }, + ); + + return next(new Request(request, { signal: innerController.signal })); + }; +} + /** * Объект, который может быть помечен как disabled. * @todo Возможно стоит заменить наследование от этого класса на передачу параметра в конструктор. * Например в виде объекта класса DisableController (по аналогии с AbortController). * Чтобы нельзя было включить обработчик в том месте где хочется. + * Также возможно стоит просто научить классы принимать AbortController. */ export class Disablable { disabled: boolean | (() => boolean); @@ -424,7 +499,7 @@ export abstract class HttpStatus { * Валидация применяется только если в конфиге запроса не указан validateStatus. * @return Промежуточный слой. */ - static axiosMiddleware(): Middleware { + static axiosMiddleware(): AxiosMiddleware { return async (config, next, defaults) => { if (config.validateStatus !== undefined || defaults.validateStatus !== undefined) { // если validateStatus указан явно то не применяем валидацию по умолчанию diff --git a/src/preset/node/handler/providers/index.tsx b/src/preset/node/handler/providers/index.tsx index a308c95..414456e 100644 --- a/src/preset/node/handler/providers/index.tsx +++ b/src/preset/node/handler/providers/index.tsx @@ -6,7 +6,6 @@ import { cookie, createCookieStore, defaultHeaders, - log, } from '../../../../http'; import type { Resolve } from '../../../../di'; import { KnownToken } from '../../../../tokens'; @@ -15,7 +14,14 @@ import { axiosTracingMiddleware, } from '../../node/utils/http-client'; import type { Middleware as AxiosMiddleware } from 'middleware-axios'; -import { AxiosLogging, FetchLogging, HttpStatus } from '../../../isomorphic/utils'; +import { + AxiosLogging, + FetchLogging, + HttpStatus, + getFetchErrorLogging, + getFetchExtraAborting, + getFetchLogging, +} from '../../../isomorphic/utils'; import { LogMiddlewareHandlerInit, cookieMiddleware, logMiddleware } from '../../../../utils/axios'; import { RESPONSE_EVENT_TYPE } from '../../../isomorphic/constants'; import type { ConventionalJson } from '../../../isomorphic/types'; @@ -169,62 +175,20 @@ export function provideFetchMiddleware(resolve: Resolve): Middleware[] { return [ // ВАЖНО: слой логирования ошибки ПЕРЕД остальными слоями чтобы не упустить ошибки выше - log(initData => { - if (typeof logHandler === 'function') { - return { - onCatch: data => logHandler(initData).onCatch?.(data), - }; - } - - return { - onCatch: data => logHandler.onCatch?.(data), - }; - }), + getFetchErrorLogging(logHandler), // пробрасываемые заголовки по соглашению defaultHeaders(getForwardedHeadersExpress(config, context.req)), // обрывание по сигналу из обработчика входящего запроса и по сигналу из конфига исходящего запроса - (request, next) => { - const innerController = new AbortController(); - - request.signal?.addEventListener( - 'abort', - () => { - innerController.abort(); - }, - { once: true }, - ); - - abortController.signal.addEventListener( - 'abort', - () => { - innerController.abort(); - }, - { once: true }, - ); - - return next(new Request(request, { signal: innerController.signal })); - }, + getFetchExtraAborting(abortController), cookie(cookieStore), getFetchTracing(tracer, context.res.locals.tracing.rootContext), // ВАЖНО: слой логирования запроса и ответа ПОСЛЕ остальных слоев чтобы использовать актуальные данные - log(initData => { - if (typeof logHandler === 'function') { - return { - onRequest: data => logHandler(initData).onRequest?.(data), - onResponse: data => logHandler(initData).onResponse?.(data), - }; - } - - return { - onRequest: data => logHandler.onRequest?.(data), - onResponse: data => logHandler.onResponse?.(data), - }; - }), + getFetchLogging(logHandler), ]; } diff --git a/src/preset/web/providers/index.ts b/src/preset/web/providers/index.ts index 52ed62b..144f4f5 100644 --- a/src/preset/web/providers/index.ts +++ b/src/preset/web/providers/index.ts @@ -112,6 +112,7 @@ export function provideKnownHttpApiHosts(resolve: Resolve): HttpApiHostPool