Skip to content

Commit

Permalink
Merge pull request #122 from sima-land/38-refactor-for-redirects
Browse files Browse the repository at this point in the history
Шаг 75 #38 Рефакторинг для редиректов
  • Loading branch information
krutoo committed Mar 21, 2024
2 parents 144859c + 6844c7d commit f2d9e98
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 6 deletions.
20 changes: 20 additions & 0 deletions src/http/__test__/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
20 changes: 17 additions & 3 deletions src/http/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { LogLevel } from '../log';
import type { ResponseErrorInit } from './types';

/**
* Ошибка валидации статуса ответа.
* @todo Переименовать в HttpStatusValidationError?
Expand Down Expand Up @@ -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;
}
}
}
1 change: 1 addition & 0 deletions src/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 8 additions & 0 deletions src/http/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { LogLevel } from '../log';

export type { Handler, Enhancer, Middleware, CookieStore } from '@krutoo/fetch-tools';
export type {
LogData,
Expand Down Expand Up @@ -26,3 +28,9 @@ export interface ResponseFail<T = unknown> {
}

export type EitherResponse<T> = ResponseDone<T> | ResponseFail<T>;

export interface ResponseErrorInit {
statusCode?: number;
redirectLocation?: string;
logLevel?: LogLevel | null;
}
3 changes: 2 additions & 1 deletion src/preset/bun/utils/get-stats-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
99 changes: 99 additions & 0 deletions src/preset/node/utils/__test__/emitter-as-target.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
54 changes: 54 additions & 0 deletions src/preset/node/utils/emitter-as-target.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
6 changes: 6 additions & 0 deletions src/preset/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
19 changes: 19 additions & 0 deletions src/preset/server/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { PageAssets } from '../isomorphic';

/**
* На сервере между промежуточными слоями надо обмениваться данными поэтому появился такой интерфейс.
* Возможно в будущем он перейдет в `@krutoo/fetch-tools`.
Expand All @@ -21,3 +23,20 @@ export interface ServerMiddleware {
context: ServerHandlerContext,
): Response | Promise<Response>;
}

export interface PageResponseFormatResult {
body: string;
headers: Headers;
}

export interface PageResponseFormatter {
(
jsx: JSX.Element,
assets: PageAssets,
meta: unknown,
): PageResponseFormatResult | Promise<PageResponseFormatResult>;
}

export interface RenderToString {
(jsx: JSX.Element): string | Promise<string>;
}
22 changes: 20 additions & 2 deletions src/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ 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';
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';

/**
* Токены компонентов.
Expand Down Expand Up @@ -114,12 +120,18 @@ export const KnownToken = {

/** Токен компонентов входящего запроса. */
Request: {
/** Токен функции которая определяет возможные типы ответа и их приоритет. */
acceptType: createToken<(types: string[]) => string | string[] | false>('handler/accepts'),

/** Токен "специфичных" параметров запроса. В зависимости от реализации определит параметры на основе объекта запроса. */
specificParams: createToken<Record<string, unknown>>('request/specific-params'),
},

/** Токены компонентов исходящего ответа. */
Response: {
/** Токен объекта для подписки на события и вызова событий ответа. */
events: createToken<EventTarget>('response/events'),

/** Токен "специфичных" дополнительных данных. В зависимости от реализации сформирует дополнительные данные ответа. */
specificExtras: createToken<SpecificExtras>('response/specific-extras'),
},
Expand All @@ -134,6 +146,12 @@ export const KnownToken = {

/** Токен "шлема". Шлем - UI-компонент, внутри которого будет выведен результат render-функции. */
helmet: createToken<ElementType<{ children: ReactNode }>>('page/helmet'),

/** Токен функции, получающей jsx и возвращающей строку. */
elementToString: createToken<RenderToString>('page/element-to-string'),

/** Токен функции, которая вернёт данные для ответа. */
formatResponse: createToken<PageResponseFormatter>('page/format-response'),
},
},
},
Expand Down

0 comments on commit f2d9e98

Please sign in to comment.