@tramvai/module-render 1.55.4 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/browser.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RenderModuleConfig } from './shared/types';
2
+ export * from './shared/pageErrorStore';
2
3
  export * from '@tramvai/tokens-render';
3
4
  export declare const DEFAULT_POLYFILL_CONDITION = "";
4
5
  export declare class RenderModule {
package/lib/browser.js CHANGED
@@ -1,41 +1,70 @@
1
1
  import { __decorate } from 'tslib';
2
- import { Module, commandLineListTokens, DI_TOKEN } from '@tramvai/core';
3
- import { LOGGER_TOKEN, CONTEXT_TOKEN } from '@tramvai/module-common';
2
+ import { Module, provide, commandLineListTokens, DI_TOKEN } from '@tramvai/core';
3
+ import { STORE_TOKEN, LOGGER_TOKEN, CONTEXT_TOKEN } from '@tramvai/module-common';
4
4
  import { DEFAULT_LAYOUT_COMPONENT, LAYOUT_OPTIONS, DEFAULT_FOOTER_COMPONENT, DEFAULT_HEADER_COMPONENT, RESOURCES_REGISTRY, CUSTOM_RENDER, EXTEND_RENDER, RENDERER_CALLBACK, RENDER_MODE } from '@tramvai/tokens-render';
5
5
  export * from '@tramvai/tokens-render';
6
- import { PAGE_SERVICE_TOKEN } from '@tramvai/tokens-router';
6
+ import { ROUTER_TOKEN, PAGE_SERVICE_TOKEN } from '@tramvai/tokens-router';
7
7
  import each from '@tinkoff/utils/array/each';
8
- import React, { PureComponent, createElement, StrictMode, useEffect } from 'react';
9
- import { Provider } from '@tramvai/state';
10
- import { withError, DIContext } from '@tramvai/react';
11
- import memoOne from '@tinkoff/utils/function/memoize/one';
12
- import strictEqual from '@tinkoff/utils/is/strictEqual';
13
- import { useRoute } from '@tramvai/module-router';
8
+ import React, { PureComponent, useMemo, createElement, StrictMode, useEffect } from 'react';
9
+ import { createEvent, createReducer, useStore, Provider } from '@tramvai/state';
10
+ import { useDi, ERROR_BOUNDARY_TOKEN, ERROR_BOUNDARY_FALLBACK_COMPONENT_TOKEN, UniversalErrorBoundary, DIContext } from '@tramvai/react';
11
+ import { useRoute, useUrl } from '@tramvai/module-router';
14
12
  import ReactDOM, { hydrate } from 'react-dom';
15
13
  import { composeLayoutOptions, createLayout } from '@tinkoff/layout-factory';
14
+ import { COMBINE_REDUCERS } from '@tramvai/tokens-common';
16
15
 
16
+ function serializeError(error) {
17
+ return {
18
+ ...error,
19
+ message: error.message,
20
+ stack: error.stack,
21
+ };
22
+ }
23
+ function deserializeError(serializedError) {
24
+ const error = new Error(serializedError.message);
25
+ Object.assign(error, serializedError);
26
+ return error;
27
+ }
28
+ const setPageErrorEvent = createEvent('setPageError');
29
+ const initialState = null;
30
+ const PageErrorStore = createReducer('pageError', initialState).on(setPageErrorEvent, (state, error) => error && serializeError(error));
31
+
32
+ const PageErrorBoundary = (props) => {
33
+ const { children, fallback } = props;
34
+ const url = useUrl();
35
+ const serializedError = useStore(PageErrorStore);
36
+ const error = useMemo(() => {
37
+ return serializedError && deserializeError(serializedError);
38
+ }, [serializedError]);
39
+ const errorHandlers = useDi({ token: ERROR_BOUNDARY_TOKEN, optional: true });
40
+ const fallbackFromDi = useDi({ token: ERROR_BOUNDARY_FALLBACK_COMPONENT_TOKEN, optional: true });
41
+ return (React.createElement(UniversalErrorBoundary, { url: url, error: error, errorHandlers: errorHandlers, fallback: fallback, fallbackFromDi: fallbackFromDi }, children));
42
+ };
17
43
  class RootComponent extends PureComponent {
18
44
  render() {
19
- const { LayoutComponent, PageComponent, HeaderComponent, FooterComponent } = this.props;
45
+ const { LayoutComponent, PageComponent, HeaderComponent, FooterComponent, ErrorBoundaryComponent, } = this.props;
20
46
  return (React.createElement(LayoutComponent, { Header: HeaderComponent, Footer: FooterComponent },
21
- React.createElement(PageComponent, null)));
47
+ React.createElement(PageErrorBoundary, { fallback: ErrorBoundaryComponent },
48
+ React.createElement(PageComponent, null))));
22
49
  }
23
50
  }
24
- const layoutWrapper = memoOne(withError(), strictEqual);
25
- const pageWrapper = memoOne(withError(), strictEqual);
26
- const Root = withError()(({ pageService }) => {
51
+ const Root = ({ pageService }) => {
27
52
  const { config } = useRoute();
28
53
  const { pageComponent } = config;
29
- const PageComponent = pageService.getComponent(pageComponent);
54
+ let PageComponent = pageService.getComponent(pageComponent);
30
55
  if (!PageComponent) {
31
- throw new Error(`Page component '${pageComponent}' not found`);
56
+ // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop
57
+ PageComponent = () => {
58
+ throw new Error(`Page component '${pageComponent}' not found`);
59
+ };
32
60
  }
33
- // Достаем компоненты для текущей страницы, либо берем default реализации
61
+ // Get components for current page, otherwise use a defaults
34
62
  const LayoutComponent = pageService.resolveComponentFromConfig('layout');
35
63
  const HeaderComponent = pageService.resolveComponentFromConfig('header');
36
64
  const FooterComponent = pageService.resolveComponentFromConfig('footer');
37
- return (React.createElement(RootComponent, { HeaderComponent: HeaderComponent, FooterComponent: FooterComponent, LayoutComponent: layoutWrapper(LayoutComponent), PageComponent: pageWrapper(PageComponent) }));
38
- });
65
+ const ErrorBoundaryComponent = pageService.resolveComponentFromConfig('errorBoundary');
66
+ return (React.createElement(RootComponent, { HeaderComponent: HeaderComponent, FooterComponent: FooterComponent, LayoutComponent: LayoutComponent, PageComponent: PageComponent, ErrorBoundaryComponent: ErrorBoundaryComponent }));
67
+ };
39
68
 
40
69
  function renderReact({ pageService, di }, context) {
41
70
  return (React.createElement(Provider, { context: context },
@@ -144,6 +173,14 @@ LayoutModule = __decorate([
144
173
  })
145
174
  ], LayoutModule);
146
175
 
176
+ const providers = [
177
+ provide({
178
+ provide: COMBINE_REDUCERS,
179
+ multi: true,
180
+ useValue: PageErrorStore,
181
+ }),
182
+ ];
183
+
147
184
  var RenderModule_1;
148
185
  const DEFAULT_POLYFILL_CONDITION = '';
149
186
  const throwErrorInDev = (logger) => {
@@ -170,7 +207,25 @@ RenderModule = RenderModule_1 = __decorate([
170
207
  Module({
171
208
  imports: [LayoutModule],
172
209
  providers: [
173
- {
210
+ ...providers,
211
+ provide({
212
+ provide: commandLineListTokens.customerStart,
213
+ multi: true,
214
+ useFactory: ({ router, store }) => {
215
+ return function clearPageError() {
216
+ router.registerHook('beforeResolve', async () => {
217
+ if (store.getState(PageErrorStore)) {
218
+ store.dispatch(setPageErrorEvent(null));
219
+ }
220
+ });
221
+ };
222
+ },
223
+ deps: {
224
+ router: ROUTER_TOKEN,
225
+ store: STORE_TOKEN,
226
+ },
227
+ }),
228
+ provide({
174
229
  provide: RESOURCES_REGISTRY,
175
230
  useFactory: ({ logger }) => ({
176
231
  getPageResources: () => {
@@ -182,8 +237,8 @@ RenderModule = RenderModule_1 = __decorate([
182
237
  deps: {
183
238
  logger: LOGGER_TOKEN,
184
239
  },
185
- },
186
- {
240
+ }),
241
+ provide({
187
242
  provide: commandLineListTokens.generatePage,
188
243
  useFactory: (deps) => {
189
244
  return function renderClientCommand() {
@@ -202,13 +257,13 @@ RenderModule = RenderModule_1 = __decorate([
202
257
  mode: RENDER_MODE,
203
258
  },
204
259
  multi: true,
205
- },
206
- {
260
+ }),
261
+ provide({
207
262
  provide: RENDER_MODE,
208
263
  useValue: 'legacy',
209
- },
264
+ }),
210
265
  ],
211
266
  })
212
267
  ], RenderModule);
213
268
 
214
- export { DEFAULT_POLYFILL_CONDITION, RenderModule };
269
+ export { DEFAULT_POLYFILL_CONDITION, PageErrorStore, RenderModule, deserializeError, serializeError, setPageErrorEvent };
@@ -1,6 +1,4 @@
1
- import React from 'react';
2
1
  import type { PAGE_SERVICE_TOKEN } from '@tramvai/tokens-router';
3
2
  export declare const Root: ({ pageService }: {
4
3
  pageService: typeof PAGE_SERVICE_TOKEN;
5
- children?: React.ReactNode;
6
4
  }) => JSX.Element;
package/lib/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RenderModuleConfig } from './shared/types';
2
+ export * from './shared/pageErrorStore';
2
3
  export * from '@tramvai/tokens-render';
3
4
  export declare const DEFAULT_POLYFILL_CONDITION = "!window.Promise.prototype.finally || !window.URL || !window.URLSearchParams || !window.AbortController || !window.IntersectionObserver || !Object.fromEntries";
4
5
  export declare class RenderModule {
package/lib/server.es.js CHANGED
@@ -1,14 +1,18 @@
1
1
  import { __decorate } from 'tslib';
2
- import { Module, commandLineListTokens, DI_TOKEN, provide } from '@tramvai/core';
3
- import { CREATE_CACHE_TOKEN, RESPONSE_MANAGER_TOKEN, CONTEXT_TOKEN } from '@tramvai/module-common';
2
+ import React, { PureComponent, useMemo, createElement } from 'react';
3
+ import { renderToString } from 'react-dom/server';
4
+ import { Module, provide, commandLineListTokens, DI_TOKEN } from '@tramvai/core';
5
+ import { CREATE_CACHE_TOKEN, LOGGER_TOKEN, REQUEST_MANAGER_TOKEN, RESPONSE_MANAGER_TOKEN, CONTEXT_TOKEN } from '@tramvai/module-common';
4
6
  import { PAGE_SERVICE_TOKEN } from '@tramvai/tokens-router';
5
7
  import { ClientHintsModule, USER_AGENT_TOKEN } from '@tramvai/module-client-hints';
6
8
  import { ResourceType, ResourceSlot, DEFAULT_LAYOUT_COMPONENT, LAYOUT_OPTIONS, DEFAULT_FOOTER_COMPONENT, DEFAULT_HEADER_COMPONENT, RESOURCES_REGISTRY, RESOURCE_INLINE_OPTIONS, RENDER_SLOTS, POLYFILL_CONDITION, HTML_ATTRS, CUSTOM_RENDER, EXTEND_RENDER } from '@tramvai/tokens-render';
7
9
  export * from '@tramvai/tokens-render';
8
10
  import { createToken, Scope } from '@tinkoff/dippy';
11
+ import { WEB_APP_AFTER_INIT_TOKEN } from '@tramvai/tokens-server';
12
+ import { useDi, ERROR_BOUNDARY_TOKEN, ERROR_BOUNDARY_FALLBACK_COMPONENT_TOKEN, UniversalErrorBoundary, DIContext, ROOT_ERROR_BOUNDARY_COMPONENT_TOKEN } from '@tramvai/react';
13
+ import { resolve, isAbsoluteUrl as isAbsoluteUrl$1, parse } from '@tinkoff/url';
9
14
  import isUndefined from '@tinkoff/utils/is/undefined';
10
15
  import isEmpty from '@tinkoff/utils/is/empty';
11
- import { resolve, isAbsoluteUrl as isAbsoluteUrl$1 } from '@tinkoff/url';
12
16
  import fetch from 'node-fetch';
13
17
  import startsWith from '@tinkoff/utils/string/startsWith';
14
18
  import toArray from '@tinkoff/utils/array/toArray';
@@ -25,14 +29,10 @@ import * as path from 'path';
25
29
  import each from '@tinkoff/utils/array/each';
26
30
  import path$1 from '@tinkoff/utils/object/path';
27
31
  import { o as onload } from './server_inline.inline.es.js';
28
- import { renderToString } from 'react-dom/server';
29
- import React, { PureComponent } from 'react';
30
- import { Provider } from '@tramvai/state';
31
- import { withError, DIContext } from '@tramvai/react';
32
- import memoOne from '@tinkoff/utils/function/memoize/one';
33
- import strictEqual from '@tinkoff/utils/is/strictEqual';
34
- import { useRoute } from '@tramvai/module-router';
32
+ import { createEvent, createReducer, useStore, Provider } from '@tramvai/state';
33
+ import { useRoute, useUrl } from '@tramvai/module-router';
35
34
  import { composeLayoutOptions, createLayout } from '@tinkoff/layout-factory';
35
+ import { COMBINE_REDUCERS } from '@tramvai/tokens-common';
36
36
 
37
37
  const thirtySeconds = 1000 * 30;
38
38
  const getFileContentLength = async (url) => {
@@ -601,28 +601,58 @@ const htmlPageSchemaFactory = ({ htmlAttrs, }) => {
601
601
  ];
602
602
  };
603
603
 
604
+ function serializeError(error) {
605
+ return {
606
+ ...error,
607
+ message: error.message,
608
+ stack: error.stack,
609
+ };
610
+ }
611
+ function deserializeError(serializedError) {
612
+ const error = new Error(serializedError.message);
613
+ Object.assign(error, serializedError);
614
+ return error;
615
+ }
616
+ const setPageErrorEvent = createEvent('setPageError');
617
+ const initialState = null;
618
+ const PageErrorStore = createReducer('pageError', initialState).on(setPageErrorEvent, (state, error) => error && serializeError(error));
619
+
620
+ const PageErrorBoundary = (props) => {
621
+ const { children, fallback } = props;
622
+ const url = useUrl();
623
+ const serializedError = useStore(PageErrorStore);
624
+ const error = useMemo(() => {
625
+ return serializedError && deserializeError(serializedError);
626
+ }, [serializedError]);
627
+ const errorHandlers = useDi({ token: ERROR_BOUNDARY_TOKEN, optional: true });
628
+ const fallbackFromDi = useDi({ token: ERROR_BOUNDARY_FALLBACK_COMPONENT_TOKEN, optional: true });
629
+ return (React.createElement(UniversalErrorBoundary, { url: url, error: error, errorHandlers: errorHandlers, fallback: fallback, fallbackFromDi: fallbackFromDi }, children));
630
+ };
604
631
  class RootComponent extends PureComponent {
605
632
  render() {
606
- const { LayoutComponent, PageComponent, HeaderComponent, FooterComponent } = this.props;
633
+ const { LayoutComponent, PageComponent, HeaderComponent, FooterComponent, ErrorBoundaryComponent, } = this.props;
607
634
  return (React.createElement(LayoutComponent, { Header: HeaderComponent, Footer: FooterComponent },
608
- React.createElement(PageComponent, null)));
635
+ React.createElement(PageErrorBoundary, { fallback: ErrorBoundaryComponent },
636
+ React.createElement(PageComponent, null))));
609
637
  }
610
638
  }
611
- const layoutWrapper = memoOne(withError(), strictEqual);
612
- const pageWrapper = memoOne(withError(), strictEqual);
613
- const Root = withError()(({ pageService }) => {
639
+ const Root = ({ pageService }) => {
614
640
  const { config } = useRoute();
615
641
  const { pageComponent } = config;
616
- const PageComponent = pageService.getComponent(pageComponent);
642
+ let PageComponent = pageService.getComponent(pageComponent);
617
643
  if (!PageComponent) {
618
- throw new Error(`Page component '${pageComponent}' not found`);
644
+ // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop
645
+ PageComponent = () => {
646
+ throw new Error(`Page component '${pageComponent}' not found`);
647
+ };
619
648
  }
620
- // Достаем компоненты для текущей страницы, либо берем default реализации
649
+ // Get components for current page, otherwise use a defaults
621
650
  const LayoutComponent = pageService.resolveComponentFromConfig('layout');
622
651
  const HeaderComponent = pageService.resolveComponentFromConfig('header');
623
652
  const FooterComponent = pageService.resolveComponentFromConfig('footer');
624
- return (React.createElement(RootComponent, { HeaderComponent: HeaderComponent, FooterComponent: FooterComponent, LayoutComponent: layoutWrapper(LayoutComponent), PageComponent: pageWrapper(PageComponent) }));
625
- });
653
+ const ErrorBoundaryComponent = pageService.resolveComponentFromConfig('errorBoundary');
654
+ return (React.createElement(RootComponent, { HeaderComponent: HeaderComponent, FooterComponent: FooterComponent, LayoutComponent: LayoutComponent, PageComponent: PageComponent, ErrorBoundaryComponent: ErrorBoundaryComponent }));
655
+ };
626
656
 
627
657
  function renderReact({ pageService, di }, context) {
628
658
  return (React.createElement(Provider, { context: context },
@@ -681,6 +711,14 @@ LayoutModule = __decorate([
681
711
  })
682
712
  ], LayoutModule);
683
713
 
714
+ const providers = [
715
+ provide({
716
+ provide: COMBINE_REDUCERS,
717
+ multi: true,
718
+ useValue: PageErrorStore,
719
+ }),
720
+ ];
721
+
684
722
  var RenderModule_1;
685
723
  const DEFAULT_POLYFILL_CONDITION = '!window.Promise.prototype.finally || !window.URL || !window.URLSearchParams || !window.AbortController || !window.IntersectionObserver || !Object.fromEntries';
686
724
  let RenderModule = RenderModule_1 = class RenderModule {
@@ -702,14 +740,15 @@ RenderModule = RenderModule_1 = __decorate([
702
740
  Module({
703
741
  imports: [ClientHintsModule, LayoutModule],
704
742
  providers: [
705
- {
743
+ ...providers,
744
+ provide({
706
745
  provide: RESOURCES_REGISTRY,
707
746
  useClass: ResourcesRegistry,
708
747
  deps: {
709
748
  resourceInliner: RESOURCE_INLINER,
710
749
  },
711
- },
712
- {
750
+ }),
751
+ provide({
713
752
  provide: RESOURCES_REGISTRY_CACHE,
714
753
  scope: Scope.SINGLETON,
715
754
  useFactory: ({ createCache }) => {
@@ -724,20 +763,40 @@ RenderModule = RenderModule_1 = __decorate([
724
763
  deps: {
725
764
  createCache: CREATE_CACHE_TOKEN,
726
765
  },
727
- },
728
- {
766
+ }),
767
+ provide({
729
768
  provide: RESOURCE_INLINER,
730
769
  useClass: ResourcesInliner,
731
770
  deps: {
732
771
  resourcesRegistryCache: RESOURCES_REGISTRY_CACHE,
733
772
  resourceInlineThreshold: { token: RESOURCE_INLINE_OPTIONS, optional: true },
734
773
  },
735
- },
736
- {
774
+ }),
775
+ provide({
737
776
  provide: commandLineListTokens.generatePage,
738
- useFactory: ({ htmlBuilder, responseManager, }) => {
777
+ useFactory: ({ htmlBuilder, logger, requestManager, responseManager, context }) => {
778
+ const log = logger('module-render');
739
779
  return async function render() {
740
- const html = await htmlBuilder.flow();
780
+ let html;
781
+ try {
782
+ html = await htmlBuilder.flow();
783
+ }
784
+ catch (error) {
785
+ const requestInfo = {
786
+ ip: requestManager.getClientIp(),
787
+ requestId: requestManager.getHeader('x-request-id'),
788
+ url: requestManager.getUrl(),
789
+ };
790
+ log.error({ event: 'page-render-error', error, requestInfo });
791
+ // Assuming that there was an error when rendering the page, try to render again with ErrorBoundary
792
+ context.dispatch(setPageErrorEvent(error));
793
+ html = await htmlBuilder.flow();
794
+ }
795
+ const pageRenderError = context.getState(PageErrorStore);
796
+ if (pageRenderError) {
797
+ const status = pageRenderError.status || pageRenderError.httpStatus || 500;
798
+ responseManager.setStatus(status);
799
+ }
741
800
  // Проставляем не кэширующие заголовки
742
801
  // TODO Заменить после выкатки на прод и прохода всех тестов на cache-control = no-cache,no-store,max-age=0,must-revalidate
743
802
  responseManager.setHeader('expires', '0');
@@ -747,12 +806,15 @@ RenderModule = RenderModule_1 = __decorate([
747
806
  };
748
807
  },
749
808
  deps: {
809
+ logger: LOGGER_TOKEN,
810
+ requestManager: REQUEST_MANAGER_TOKEN,
750
811
  responseManager: RESPONSE_MANAGER_TOKEN,
751
812
  htmlBuilder: 'htmlBuilder',
813
+ context: CONTEXT_TOKEN,
752
814
  },
753
815
  multi: true,
754
- },
755
- {
816
+ }),
817
+ provide({
756
818
  provide: 'htmlBuilder',
757
819
  useClass: PageBuilder,
758
820
  deps: {
@@ -766,8 +828,8 @@ RenderModule = RenderModule_1 = __decorate([
766
828
  userAgent: USER_AGENT_TOKEN,
767
829
  htmlAttrs: HTML_ATTRS,
768
830
  },
769
- },
770
- {
831
+ }),
832
+ provide({
771
833
  provide: 'reactRender',
772
834
  useClass: ReactRenderServer,
773
835
  deps: {
@@ -777,15 +839,15 @@ RenderModule = RenderModule_1 = __decorate([
777
839
  extendRender: { token: EXTEND_RENDER, optional: true },
778
840
  di: DI_TOKEN,
779
841
  },
780
- },
781
- {
842
+ }),
843
+ provide({
782
844
  provide: 'htmlPageSchema',
783
845
  useFactory: htmlPageSchemaFactory,
784
846
  deps: {
785
847
  htmlAttrs: HTML_ATTRS,
786
848
  },
787
- },
788
- {
849
+ }),
850
+ provide({
789
851
  provide: HTML_ATTRS,
790
852
  useValue: {
791
853
  target: 'html',
@@ -795,8 +857,8 @@ RenderModule = RenderModule_1 = __decorate([
795
857
  },
796
858
  },
797
859
  multi: true,
798
- },
799
- {
860
+ }),
861
+ provide({
800
862
  provide: HTML_ATTRS,
801
863
  useValue: {
802
864
  target: 'app',
@@ -805,11 +867,11 @@ RenderModule = RenderModule_1 = __decorate([
805
867
  },
806
868
  },
807
869
  multi: true,
808
- },
809
- {
870
+ }),
871
+ provide({
810
872
  provide: POLYFILL_CONDITION,
811
873
  useValue: DEFAULT_POLYFILL_CONDITION,
812
- },
874
+ }),
813
875
  provide({
814
876
  // Включаем инлайнинг CSS-файлов размером до 40кб (до gzip) по умолчанию.
815
877
  provide: RESOURCE_INLINE_OPTIONS,
@@ -818,8 +880,40 @@ RenderModule = RenderModule_1 = __decorate([
818
880
  types: [ResourceType.style],
819
881
  },
820
882
  }),
883
+ provide({
884
+ provide: WEB_APP_AFTER_INIT_TOKEN,
885
+ multi: true,
886
+ useFactory: ({ RootErrorBoundary, logger }) => {
887
+ const log = logger('module-render:error-handler');
888
+ return (app) => {
889
+ app.use((err, req, res, next) => {
890
+ if (!RootErrorBoundary) {
891
+ return next(err);
892
+ }
893
+ let body;
894
+ try {
895
+ log.info({ event: 'render-root-boundary' });
896
+ body = renderToString(createElement(RootErrorBoundary, { error: err, url: parse(req.url) }));
897
+ res.status(err.httpStatus || err.status || 500);
898
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
899
+ res.setHeader('Content-Length', Buffer.byteLength(body, 'utf8'));
900
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
901
+ return res.send(body);
902
+ }
903
+ catch (e) {
904
+ log.warn({ event: 'render-root-boundary-error', error: e });
905
+ return next(err);
906
+ }
907
+ });
908
+ };
909
+ },
910
+ deps: {
911
+ RootErrorBoundary: { token: ROOT_ERROR_BOUNDARY_COMPONENT_TOKEN, optional: true },
912
+ logger: LOGGER_TOKEN,
913
+ },
914
+ }),
821
915
  ],
822
916
  })
823
917
  ], RenderModule);
824
918
 
825
- export { DEFAULT_POLYFILL_CONDITION, RenderModule };
919
+ export { DEFAULT_POLYFILL_CONDITION, PageErrorStore, RenderModule, deserializeError, serializeError, setPageErrorEvent };
package/lib/server.js CHANGED
@@ -3,15 +3,19 @@
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  var tslib = require('tslib');
6
+ var React = require('react');
7
+ var server$1 = require('react-dom/server');
6
8
  var core = require('@tramvai/core');
7
9
  var moduleCommon = require('@tramvai/module-common');
8
10
  var tokensRouter = require('@tramvai/tokens-router');
9
11
  var moduleClientHints = require('@tramvai/module-client-hints');
10
12
  var tokensRender = require('@tramvai/tokens-render');
11
13
  var dippy = require('@tinkoff/dippy');
14
+ var tokensServer = require('@tramvai/tokens-server');
15
+ var react = require('@tramvai/react');
16
+ var url = require('@tinkoff/url');
12
17
  var isUndefined = require('@tinkoff/utils/is/undefined');
13
18
  var isEmpty = require('@tinkoff/utils/is/empty');
14
- var url = require('@tinkoff/url');
15
19
  var fetch = require('node-fetch');
16
20
  var startsWith = require('@tinkoff/utils/string/startsWith');
17
21
  var toArray = require('@tinkoff/utils/array/toArray');
@@ -28,14 +32,10 @@ var path = require('path');
28
32
  var each = require('@tinkoff/utils/array/each');
29
33
  var path$1 = require('@tinkoff/utils/object/path');
30
34
  var inline_inline = require('./server_inline.inline.js');
31
- var server$1 = require('react-dom/server');
32
- var React = require('react');
33
35
  var state = require('@tramvai/state');
34
- var react = require('@tramvai/react');
35
- var memoOne = require('@tinkoff/utils/function/memoize/one');
36
- var strictEqual = require('@tinkoff/utils/is/strictEqual');
37
36
  var moduleRouter = require('@tramvai/module-router');
38
37
  var layoutFactory = require('@tinkoff/layout-factory');
38
+ var tokensCommon = require('@tramvai/tokens-common');
39
39
 
40
40
  function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
41
41
 
@@ -57,6 +57,7 @@ function _interopNamespace(e) {
57
57
  return n;
58
58
  }
59
59
 
60
+ var React__default = /*#__PURE__*/_interopDefaultLegacy(React);
60
61
  var isUndefined__default = /*#__PURE__*/_interopDefaultLegacy(isUndefined);
61
62
  var isEmpty__default = /*#__PURE__*/_interopDefaultLegacy(isEmpty);
62
63
  var fetch__default = /*#__PURE__*/_interopDefaultLegacy(fetch);
@@ -69,9 +70,6 @@ var uniq__default = /*#__PURE__*/_interopDefaultLegacy(uniq);
69
70
  var path__namespace = /*#__PURE__*/_interopNamespace(path);
70
71
  var each__default = /*#__PURE__*/_interopDefaultLegacy(each);
71
72
  var path__default = /*#__PURE__*/_interopDefaultLegacy(path$1);
72
- var React__default = /*#__PURE__*/_interopDefaultLegacy(React);
73
- var memoOne__default = /*#__PURE__*/_interopDefaultLegacy(memoOne);
74
- var strictEqual__default = /*#__PURE__*/_interopDefaultLegacy(strictEqual);
75
73
 
76
74
  const thirtySeconds = 1000 * 30;
77
75
  const getFileContentLength = async (url) => {
@@ -640,28 +638,58 @@ const htmlPageSchemaFactory = ({ htmlAttrs, }) => {
640
638
  ];
641
639
  };
642
640
 
641
+ function serializeError(error) {
642
+ return {
643
+ ...error,
644
+ message: error.message,
645
+ stack: error.stack,
646
+ };
647
+ }
648
+ function deserializeError(serializedError) {
649
+ const error = new Error(serializedError.message);
650
+ Object.assign(error, serializedError);
651
+ return error;
652
+ }
653
+ const setPageErrorEvent = state.createEvent('setPageError');
654
+ const initialState = null;
655
+ const PageErrorStore = state.createReducer('pageError', initialState).on(setPageErrorEvent, (state, error) => error && serializeError(error));
656
+
657
+ const PageErrorBoundary = (props) => {
658
+ const { children, fallback } = props;
659
+ const url = moduleRouter.useUrl();
660
+ const serializedError = state.useStore(PageErrorStore);
661
+ const error = React.useMemo(() => {
662
+ return serializedError && deserializeError(serializedError);
663
+ }, [serializedError]);
664
+ const errorHandlers = react.useDi({ token: react.ERROR_BOUNDARY_TOKEN, optional: true });
665
+ const fallbackFromDi = react.useDi({ token: react.ERROR_BOUNDARY_FALLBACK_COMPONENT_TOKEN, optional: true });
666
+ return (React__default["default"].createElement(react.UniversalErrorBoundary, { url: url, error: error, errorHandlers: errorHandlers, fallback: fallback, fallbackFromDi: fallbackFromDi }, children));
667
+ };
643
668
  class RootComponent extends React.PureComponent {
644
669
  render() {
645
- const { LayoutComponent, PageComponent, HeaderComponent, FooterComponent } = this.props;
670
+ const { LayoutComponent, PageComponent, HeaderComponent, FooterComponent, ErrorBoundaryComponent, } = this.props;
646
671
  return (React__default["default"].createElement(LayoutComponent, { Header: HeaderComponent, Footer: FooterComponent },
647
- React__default["default"].createElement(PageComponent, null)));
672
+ React__default["default"].createElement(PageErrorBoundary, { fallback: ErrorBoundaryComponent },
673
+ React__default["default"].createElement(PageComponent, null))));
648
674
  }
649
675
  }
650
- const layoutWrapper = memoOne__default["default"](react.withError(), strictEqual__default["default"]);
651
- const pageWrapper = memoOne__default["default"](react.withError(), strictEqual__default["default"]);
652
- const Root = react.withError()(({ pageService }) => {
676
+ const Root = ({ pageService }) => {
653
677
  const { config } = moduleRouter.useRoute();
654
678
  const { pageComponent } = config;
655
- const PageComponent = pageService.getComponent(pageComponent);
679
+ let PageComponent = pageService.getComponent(pageComponent);
656
680
  if (!PageComponent) {
657
- throw new Error(`Page component '${pageComponent}' not found`);
681
+ // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop
682
+ PageComponent = () => {
683
+ throw new Error(`Page component '${pageComponent}' not found`);
684
+ };
658
685
  }
659
- // Достаем компоненты для текущей страницы, либо берем default реализации
686
+ // Get components for current page, otherwise use a defaults
660
687
  const LayoutComponent = pageService.resolveComponentFromConfig('layout');
661
688
  const HeaderComponent = pageService.resolveComponentFromConfig('header');
662
689
  const FooterComponent = pageService.resolveComponentFromConfig('footer');
663
- return (React__default["default"].createElement(RootComponent, { HeaderComponent: HeaderComponent, FooterComponent: FooterComponent, LayoutComponent: layoutWrapper(LayoutComponent), PageComponent: pageWrapper(PageComponent) }));
664
- });
690
+ const ErrorBoundaryComponent = pageService.resolveComponentFromConfig('errorBoundary');
691
+ return (React__default["default"].createElement(RootComponent, { HeaderComponent: HeaderComponent, FooterComponent: FooterComponent, LayoutComponent: LayoutComponent, PageComponent: PageComponent, ErrorBoundaryComponent: ErrorBoundaryComponent }));
692
+ };
665
693
 
666
694
  function renderReact({ pageService, di }, context) {
667
695
  return (React__default["default"].createElement(state.Provider, { context: context },
@@ -720,6 +748,14 @@ LayoutModule = tslib.__decorate([
720
748
  })
721
749
  ], LayoutModule);
722
750
 
751
+ const providers = [
752
+ core.provide({
753
+ provide: tokensCommon.COMBINE_REDUCERS,
754
+ multi: true,
755
+ useValue: PageErrorStore,
756
+ }),
757
+ ];
758
+
723
759
  var RenderModule_1;
724
760
  const DEFAULT_POLYFILL_CONDITION = '!window.Promise.prototype.finally || !window.URL || !window.URLSearchParams || !window.AbortController || !window.IntersectionObserver || !Object.fromEntries';
725
761
  exports.RenderModule = RenderModule_1 = class RenderModule {
@@ -741,14 +777,15 @@ exports.RenderModule = RenderModule_1 = tslib.__decorate([
741
777
  core.Module({
742
778
  imports: [moduleClientHints.ClientHintsModule, LayoutModule],
743
779
  providers: [
744
- {
780
+ ...providers,
781
+ core.provide({
745
782
  provide: tokensRender.RESOURCES_REGISTRY,
746
783
  useClass: ResourcesRegistry,
747
784
  deps: {
748
785
  resourceInliner: RESOURCE_INLINER,
749
786
  },
750
- },
751
- {
787
+ }),
788
+ core.provide({
752
789
  provide: RESOURCES_REGISTRY_CACHE,
753
790
  scope: dippy.Scope.SINGLETON,
754
791
  useFactory: ({ createCache }) => {
@@ -763,20 +800,40 @@ exports.RenderModule = RenderModule_1 = tslib.__decorate([
763
800
  deps: {
764
801
  createCache: moduleCommon.CREATE_CACHE_TOKEN,
765
802
  },
766
- },
767
- {
803
+ }),
804
+ core.provide({
768
805
  provide: RESOURCE_INLINER,
769
806
  useClass: ResourcesInliner,
770
807
  deps: {
771
808
  resourcesRegistryCache: RESOURCES_REGISTRY_CACHE,
772
809
  resourceInlineThreshold: { token: tokensRender.RESOURCE_INLINE_OPTIONS, optional: true },
773
810
  },
774
- },
775
- {
811
+ }),
812
+ core.provide({
776
813
  provide: core.commandLineListTokens.generatePage,
777
- useFactory: ({ htmlBuilder, responseManager, }) => {
814
+ useFactory: ({ htmlBuilder, logger, requestManager, responseManager, context }) => {
815
+ const log = logger('module-render');
778
816
  return async function render() {
779
- const html = await htmlBuilder.flow();
817
+ let html;
818
+ try {
819
+ html = await htmlBuilder.flow();
820
+ }
821
+ catch (error) {
822
+ const requestInfo = {
823
+ ip: requestManager.getClientIp(),
824
+ requestId: requestManager.getHeader('x-request-id'),
825
+ url: requestManager.getUrl(),
826
+ };
827
+ log.error({ event: 'page-render-error', error, requestInfo });
828
+ // Assuming that there was an error when rendering the page, try to render again with ErrorBoundary
829
+ context.dispatch(setPageErrorEvent(error));
830
+ html = await htmlBuilder.flow();
831
+ }
832
+ const pageRenderError = context.getState(PageErrorStore);
833
+ if (pageRenderError) {
834
+ const status = pageRenderError.status || pageRenderError.httpStatus || 500;
835
+ responseManager.setStatus(status);
836
+ }
780
837
  // Проставляем не кэширующие заголовки
781
838
  // TODO Заменить после выкатки на прод и прохода всех тестов на cache-control = no-cache,no-store,max-age=0,must-revalidate
782
839
  responseManager.setHeader('expires', '0');
@@ -786,12 +843,15 @@ exports.RenderModule = RenderModule_1 = tslib.__decorate([
786
843
  };
787
844
  },
788
845
  deps: {
846
+ logger: moduleCommon.LOGGER_TOKEN,
847
+ requestManager: moduleCommon.REQUEST_MANAGER_TOKEN,
789
848
  responseManager: moduleCommon.RESPONSE_MANAGER_TOKEN,
790
849
  htmlBuilder: 'htmlBuilder',
850
+ context: moduleCommon.CONTEXT_TOKEN,
791
851
  },
792
852
  multi: true,
793
- },
794
- {
853
+ }),
854
+ core.provide({
795
855
  provide: 'htmlBuilder',
796
856
  useClass: PageBuilder,
797
857
  deps: {
@@ -805,8 +865,8 @@ exports.RenderModule = RenderModule_1 = tslib.__decorate([
805
865
  userAgent: moduleClientHints.USER_AGENT_TOKEN,
806
866
  htmlAttrs: tokensRender.HTML_ATTRS,
807
867
  },
808
- },
809
- {
868
+ }),
869
+ core.provide({
810
870
  provide: 'reactRender',
811
871
  useClass: ReactRenderServer,
812
872
  deps: {
@@ -816,15 +876,15 @@ exports.RenderModule = RenderModule_1 = tslib.__decorate([
816
876
  extendRender: { token: tokensRender.EXTEND_RENDER, optional: true },
817
877
  di: core.DI_TOKEN,
818
878
  },
819
- },
820
- {
879
+ }),
880
+ core.provide({
821
881
  provide: 'htmlPageSchema',
822
882
  useFactory: htmlPageSchemaFactory,
823
883
  deps: {
824
884
  htmlAttrs: tokensRender.HTML_ATTRS,
825
885
  },
826
- },
827
- {
886
+ }),
887
+ core.provide({
828
888
  provide: tokensRender.HTML_ATTRS,
829
889
  useValue: {
830
890
  target: 'html',
@@ -834,8 +894,8 @@ exports.RenderModule = RenderModule_1 = tslib.__decorate([
834
894
  },
835
895
  },
836
896
  multi: true,
837
- },
838
- {
897
+ }),
898
+ core.provide({
839
899
  provide: tokensRender.HTML_ATTRS,
840
900
  useValue: {
841
901
  target: 'app',
@@ -844,11 +904,11 @@ exports.RenderModule = RenderModule_1 = tslib.__decorate([
844
904
  },
845
905
  },
846
906
  multi: true,
847
- },
848
- {
907
+ }),
908
+ core.provide({
849
909
  provide: tokensRender.POLYFILL_CONDITION,
850
910
  useValue: DEFAULT_POLYFILL_CONDITION,
851
- },
911
+ }),
852
912
  core.provide({
853
913
  // Включаем инлайнинг CSS-файлов размером до 40кб (до gzip) по умолчанию.
854
914
  provide: tokensRender.RESOURCE_INLINE_OPTIONS,
@@ -857,11 +917,47 @@ exports.RenderModule = RenderModule_1 = tslib.__decorate([
857
917
  types: [tokensRender.ResourceType.style],
858
918
  },
859
919
  }),
920
+ core.provide({
921
+ provide: tokensServer.WEB_APP_AFTER_INIT_TOKEN,
922
+ multi: true,
923
+ useFactory: ({ RootErrorBoundary, logger }) => {
924
+ const log = logger('module-render:error-handler');
925
+ return (app) => {
926
+ app.use((err, req, res, next) => {
927
+ if (!RootErrorBoundary) {
928
+ return next(err);
929
+ }
930
+ let body;
931
+ try {
932
+ log.info({ event: 'render-root-boundary' });
933
+ body = server$1.renderToString(React.createElement(RootErrorBoundary, { error: err, url: url.parse(req.url) }));
934
+ res.status(err.httpStatus || err.status || 500);
935
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
936
+ res.setHeader('Content-Length', Buffer.byteLength(body, 'utf8'));
937
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
938
+ return res.send(body);
939
+ }
940
+ catch (e) {
941
+ log.warn({ event: 'render-root-boundary-error', error: e });
942
+ return next(err);
943
+ }
944
+ });
945
+ };
946
+ },
947
+ deps: {
948
+ RootErrorBoundary: { token: react.ROOT_ERROR_BOUNDARY_COMPONENT_TOKEN, optional: true },
949
+ logger: moduleCommon.LOGGER_TOKEN,
950
+ },
951
+ }),
860
952
  ],
861
953
  })
862
954
  ], exports.RenderModule);
863
955
 
864
956
  exports.DEFAULT_POLYFILL_CONDITION = DEFAULT_POLYFILL_CONDITION;
957
+ exports.PageErrorStore = PageErrorStore;
958
+ exports.deserializeError = deserializeError;
959
+ exports.serializeError = serializeError;
960
+ exports.setPageErrorEvent = setPageErrorEvent;
865
961
  Object.keys(tokensRender).forEach(function (k) {
866
962
  if (k !== 'default' && !exports.hasOwnProperty(k)) Object.defineProperty(exports, k, {
867
963
  enumerable: true,
@@ -0,0 +1,14 @@
1
+ declare type AnyError = Error & {
2
+ [key: string]: any;
3
+ };
4
+ export interface SerializedError {
5
+ message: string;
6
+ stack?: string;
7
+ [key: string]: any;
8
+ }
9
+ export declare function serializeError(error: AnyError): SerializedError;
10
+ export declare function deserializeError(serializedError: SerializedError): AnyError;
11
+ export declare type IPageErrorStore = SerializedError | null;
12
+ export declare const setPageErrorEvent: import("@tramvai/types-actions-state-context").EventCreator1<AnyError, AnyError>;
13
+ export declare const PageErrorStore: import("@tramvai/state").Reducer<SerializedError, "pageError">;
14
+ export {};
@@ -0,0 +1 @@
1
+ export declare const providers: import("@tramvai/core").Provider<unknown, any>[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tramvai/module-render",
3
- "version": "1.55.4",
3
+ "version": "1.58.0",
4
4
  "description": "",
5
5
  "browser": "lib/browser.js",
6
6
  "main": "lib/server.js",
@@ -24,31 +24,36 @@
24
24
  "@tinkoff/htmlpagebuilder": "0.4.22",
25
25
  "@tinkoff/layout-factory": "0.2.28",
26
26
  "@tinkoff/url": "0.7.37",
27
- "@tinkoff/user-agent": "0.3.232",
28
- "@tramvai/module-client-hints": "1.55.4",
29
- "@tramvai/module-router": "1.55.4",
30
- "@tramvai/react": "1.55.4",
27
+ "@tinkoff/user-agent": "0.3.238",
28
+ "@tramvai/module-client-hints": "1.58.0",
29
+ "@tramvai/module-router": "1.58.0",
30
+ "@tramvai/react": "1.58.0",
31
31
  "@tramvai/safe-strings": "0.4.3",
32
- "@tramvai/tokens-render": "1.55.4",
33
- "@tramvai/experiments": "1.55.4",
32
+ "@tramvai/tokens-render": "1.58.0",
33
+ "@tramvai/experiments": "1.58.0",
34
34
  "@types/loadable__server": "^5.12.6",
35
35
  "node-fetch": "^2.6.1"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@tinkoff/dippy": "0.7.38",
39
39
  "@tinkoff/utils": "^2.1.2",
40
- "@tramvai/cli": "1.55.4",
41
- "@tramvai/core": "1.55.4",
42
- "@tramvai/module-common": "1.55.4",
43
- "@tramvai/state": "1.55.4",
44
- "@tramvai/test-helpers": "1.55.4",
45
- "@tramvai/tokens-common": "1.55.4",
46
- "@tramvai/tokens-router": "1.55.4",
40
+ "@tramvai/cli": "1.58.0",
41
+ "@tramvai/core": "1.58.0",
42
+ "@tramvai/module-common": "1.58.0",
43
+ "@tramvai/state": "1.58.0",
44
+ "@tramvai/test-helpers": "1.58.0",
45
+ "@tramvai/tokens-common": "1.58.0",
46
+ "@tramvai/tokens-router": "1.58.0",
47
+ "@tramvai/tokens-server": "1.58.0",
48
+ "express": "^4.17.1",
47
49
  "prop-types": "^15.6.2",
48
50
  "react": ">=16.8.0",
49
51
  "react-dom": ">=16.8.0",
50
52
  "tslib": "^2.0.3"
51
53
  },
54
+ "devDependencies": {
55
+ "@types/express": "^4.17.9"
56
+ },
52
57
  "gitHead": "8e826a214c87b188fc4d254cdd8f2a2b2c55f3a8",
53
58
  "module": "lib/server.es.js",
54
59
  "license": "Apache-2.0"