diff --git a/src/http/__test__/errors.test.ts b/src/http/__test__/errors.test.ts index a10a7ad..609201e 100644 --- a/src/http/__test__/errors.test.ts +++ b/src/http/__test__/errors.test.ts @@ -32,4 +32,24 @@ describe('ResponseError', () => { expect(error.message).toBe('Hello!'); expect(error.statusCode).toBe(500); }); + + it('should handle init', () => { + const error = new ResponseError('Need redirect', { + statusCode: 301, + redirectLocation: '/hello.php', + logLevel: 'debug', + }); + + expect(error.logLevel).toBe('debug'); + expect(error.statusCode).toBe(301); + expect(error.redirectLocation).toBe('/hello.php'); + }); + + it('should handle empty init', () => { + const error = new ResponseError('Need redirect', {}); + + expect(error.logLevel).toBe('error'); + expect(error.statusCode).toBe(500); + expect(error.redirectLocation).toBe(null); + }); }); diff --git a/src/http/errors.ts b/src/http/errors.ts index 9277272..d7dbda7 100644 --- a/src/http/errors.ts +++ b/src/http/errors.ts @@ -1,3 +1,6 @@ +import type { LogLevel } from '../log'; +import type { ResponseErrorInit } from './types'; + /** * Ошибка валидации статуса ответа. * @todo Переименовать в HttpStatusValidationError? @@ -28,15 +31,26 @@ export class StatusError extends Error { * Ошибка в процессе формирования ответа. */ export class ResponseError extends Error { + logLevel: LogLevel | null; statusCode: number; + redirectLocation: string | null; /** * @param message Сообщение. - * @param statusCode Код ответа. + * @param statusCodeOrInit Код ответа. */ - constructor(message: string, statusCode = 500) { + constructor(message: string, statusCodeOrInit: number | ResponseErrorInit = 500) { super(message); this.name = 'ResponseError'; - this.statusCode = statusCode; + + if (typeof statusCodeOrInit === 'number') { + this.logLevel = 'error'; + this.statusCode = statusCodeOrInit; + this.redirectLocation = null; + } else { + this.logLevel = statusCodeOrInit.logLevel ?? 'error'; + this.statusCode = statusCodeOrInit.statusCode ?? 500; + this.redirectLocation = statusCodeOrInit.redirectLocation ?? null; + } } } diff --git a/src/http/index.ts b/src/http/index.ts index 42acc55..4303d02 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -11,6 +11,7 @@ export type { EitherResponse, ResponseDone, ResponseFail, + ResponseErrorInit, } from './types'; export { configureFetch, applyMiddleware, createCookieStore } from '@krutoo/fetch-tools'; export { log, cookie, defaultHeaders, validateStatus } from '@krutoo/fetch-tools/middleware'; diff --git a/src/http/types.ts b/src/http/types.ts index 702d8a5..3f4e633 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -1,3 +1,5 @@ +import { LogLevel } from '../log'; + export type { Handler, Enhancer, Middleware, CookieStore } from '@krutoo/fetch-tools'; export type { LogData, @@ -26,3 +28,9 @@ export interface ResponseFail { } export type EitherResponse = ResponseDone | ResponseFail; + +export interface ResponseErrorInit { + statusCode?: number; + redirectLocation?: string; + logLevel?: LogLevel | null; +} diff --git a/src/preset/bun/utils/get-stats-handler.ts b/src/preset/bun/utils/get-stats-handler.ts index 01acaf1..cb6141f 100644 --- a/src/preset/bun/utils/get-stats-handler.ts +++ b/src/preset/bun/utils/get-stats-handler.ts @@ -7,9 +7,10 @@ import type { Handler } from '../../../http'; export function getStatsHandler(): Handler { /** @inheritdoc */ const getHeapStats = async () => { + // ВАЖНО: должны быть именно кавычки "'" по причине https://github.com/web-infra-dev/rspack/issues/5938#issuecomment-2000393152 const jsc = await import( /* webpackIgnore: true */ - `bun:jsc` + 'bun:jsc' ); return jsc.heapStats(); diff --git a/src/preset/node/utils/__test__/emitter-as-target.test.ts b/src/preset/node/utils/__test__/emitter-as-target.test.ts new file mode 100644 index 0000000..9f7b717 --- /dev/null +++ b/src/preset/node/utils/__test__/emitter-as-target.test.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from 'node:events'; +import { EmitterAsTarget } from '../emitter-as-target'; + +describe('EmitterAsTarget', () => { + it('should works as regular event target', () => { + const emitter = new EventEmitter(); + const target = new EmitterAsTarget(emitter); + + const spy = jest.fn(); + + target.addEventListener('test', spy); + expect(spy).toHaveBeenCalledTimes(0); + + target.dispatchEvent(new Event('test')); + expect(spy).toHaveBeenCalledTimes(1); + + target.dispatchEvent(new Event('test')); + expect(spy).toHaveBeenCalledTimes(2); + + target.removeEventListener('test', spy); + expect(spy).toHaveBeenCalledTimes(2); + + target.dispatchEvent(new Event('test')); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should callback as object', () => { + const emitter = new EventEmitter(); + const target = new EmitterAsTarget(emitter); + + const spy = jest.fn(); + + target.addEventListener('test', { handleEvent: spy }); + expect(spy).toHaveBeenCalledTimes(0); + + target.dispatchEvent(new Event('test')); + expect(spy).toHaveBeenCalledTimes(1); + + target.dispatchEvent(new Event('test')); + expect(spy).toHaveBeenCalledTimes(2); + + target.removeEventListener('test', { handleEvent: spy }); + expect(spy).toHaveBeenCalledTimes(2); + + target.dispatchEvent(new Event('test')); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should handle callback as null', () => { + const emitter = new EventEmitter(); + const target = new EmitterAsTarget(emitter); + + expect(() => { + target.addEventListener('foo', null); + }).not.toThrow(); + + expect(() => { + target.removeEventListener('foo', null); + }).not.toThrow(); + }); + + it('should work through emitter', () => { + const emitter = new EventEmitter(); + const target = new EmitterAsTarget(emitter); + + const spy = jest.fn(); + + emitter.on('test', spy); + expect(spy).toHaveBeenCalledTimes(0); + + target.dispatchEvent(new Event('test')); + expect(spy).toHaveBeenCalledTimes(1); + + target.dispatchEvent(new Event('test')); + expect(spy).toHaveBeenCalledTimes(2); + + emitter.removeListener('test', spy); + expect(spy).toHaveBeenCalledTimes(2); + + target.dispatchEvent(new Event('test')); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should handle options.once', () => { + const emitter = new EventEmitter(); + const target = new EmitterAsTarget(emitter); + + const spy = jest.fn(); + + target.addEventListener('foo', spy, { once: true }); + expect(spy).toHaveBeenCalledTimes(0); + + target.dispatchEvent(new Event('foo')); + expect(spy).toHaveBeenCalledTimes(1); + + target.dispatchEvent(new Event('foo')); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/preset/node/utils/emitter-as-target.ts b/src/preset/node/utils/emitter-as-target.ts new file mode 100644 index 0000000..bfce184 --- /dev/null +++ b/src/preset/node/utils/emitter-as-target.ts @@ -0,0 +1,54 @@ +import type { EventEmitter } from 'node:events'; + +/** + * Наивная реализация обёртки, превращающей EventEmitter в EventTarget. + */ +export class EmitterAsTarget extends EventTarget { + private emitter: EventEmitter; + + /** @inheritdoc */ + constructor(emitter: EventEmitter) { + super(); + this.emitter = emitter; + } + + /** @inheritdoc */ + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: AddEventListenerOptions | boolean, + ) { + if (!callback) { + return; + } + + const listener = typeof callback === 'function' ? callback : callback.handleEvent; + + switch (true) { + case typeof options === 'object' && options !== null && options.once: { + this.emitter.once(type, listener as (...args: any[]) => void); + break; + } + default: { + this.emitter.on(type, listener as (...args: any[]) => void); + break; + } + } + } + + /** @inheritdoc */ + removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null) { + if (!callback) { + return; + } + + const listener = typeof callback === 'function' ? callback : callback.handleEvent; + + this.emitter.removeListener(type, listener as (...args: any[]) => void); + } + + /** @inheritdoc */ + dispatchEvent(event: Event) { + return this.emitter.emit(event.type, event); + } +} diff --git a/src/preset/server/constants.ts b/src/preset/server/constants.ts index e485af9..87ded07 100644 --- a/src/preset/server/constants.ts +++ b/src/preset/server/constants.ts @@ -6,3 +6,9 @@ export const PAGE_HANDLER_EVENT_TYPE = { renderStart: 'isomorph/render:start', renderFinish: 'isomorph/render:finish', } as const; + +/** + * Приоритет форматов ответа на запрос страницы. + * Нужен для использования вместе с пакетом accepts. + */ +export const PAGE_FORMAT_PRIORITY = ['json', 'html']; diff --git a/src/preset/server/types.ts b/src/preset/server/types.ts index 821370b..7700237 100644 --- a/src/preset/server/types.ts +++ b/src/preset/server/types.ts @@ -1,3 +1,5 @@ +import type { PageAssets } from '../isomorphic'; + /** * На сервере между промежуточными слоями надо обмениваться данными поэтому появился такой интерфейс. * Возможно в будущем он перейдет в `@krutoo/fetch-tools`. @@ -21,3 +23,20 @@ export interface ServerMiddleware { context: ServerHandlerContext, ): Response | Promise; } + +export interface PageResponseFormatResult { + body: string; + headers: Headers; +} + +export interface PageResponseFormatter { + ( + jsx: JSX.Element, + assets: PageAssets, + meta: unknown, + ): PageResponseFormatResult | Promise; +} + +export interface RenderToString { + (jsx: JSX.Element): string | Promise; +} diff --git a/src/tokens.ts b/src/tokens.ts index 4279ce4..df2c440 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -11,7 +11,7 @@ import type { BridgeClientSide, BridgeServerSide } from './utils/ssr'; import type { Tracer } from '@opentelemetry/api'; import type { BasicTracerProvider, SpanExporter } from '@opentelemetry/sdk-trace-base'; import type { Resource } from '@opentelemetry/resources'; -import type { ElementType, ReactNode } from 'react'; +import type { ElementType, ReactNode, JSX } from 'react'; import type { KnownHttpApiKey, PageAssets } from './preset/isomorphic/types'; import type { ExpressHandlerContext } from './preset/node/types'; import type { SpecificExtras } from './preset/server/utils/specific-extras'; @@ -19,7 +19,13 @@ import type { CreateAxiosDefaults } from 'axios'; import type { AxiosInstanceWrapper, Middleware as AxiosMiddleware } from 'middleware-axios'; import type { CookieStore, Handler, LogHandler, LogHandlerFactory, Middleware } from './http'; import type { HttpApiHostPool } from './preset/isomorphic/utils/http-api-host-pool'; -import type { ServerHandlerContext, ServerHandler, ServerMiddleware } from './preset/server/types'; +import type { + ServerHandlerContext, + ServerHandler, + ServerMiddleware, + PageResponseFormatter, + RenderToString, +} from './preset/server/types'; /** * Токены компонентов. @@ -114,12 +120,18 @@ export const KnownToken = { /** Токен компонентов входящего запроса. */ Request: { + /** Токен функции которая определяет возможные типы ответа и их приоритет. */ + acceptType: createToken<(types: string[]) => string | string[] | false>('handler/accepts'), + /** Токен "специфичных" параметров запроса. В зависимости от реализации определит параметры на основе объекта запроса. */ specificParams: createToken>('request/specific-params'), }, /** Токены компонентов исходящего ответа. */ Response: { + /** Токен объекта для подписки на события и вызова событий ответа. */ + events: createToken('response/events'), + /** Токен "специфичных" дополнительных данных. В зависимости от реализации сформирует дополнительные данные ответа. */ specificExtras: createToken('response/specific-extras'), }, @@ -134,6 +146,12 @@ export const KnownToken = { /** Токен "шлема". Шлем - UI-компонент, внутри которого будет выведен результат render-функции. */ helmet: createToken>('page/helmet'), + + /** Токен функции, получающей jsx и возвращающей строку. */ + elementToString: createToken('page/element-to-string'), + + /** Токен функции, которая вернёт данные для ответа. */ + formatResponse: createToken('page/format-response'), }, }, },