catalyst-core 0.2.0-beta.2 → 0.3.0-beta.1
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/babel.native.config.cjs +8 -0
- package/bin/catalyst.js +49 -105
- package/changelog.md +8 -0
- package/dist/caching.js +1 -1
- package/dist/index.jsx +8 -0
- package/dist/logger.js +74 -3
- package/dist/native/androidProject/app/build.gradle.kts +1 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +143 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +26 -6
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +6 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +26 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
- package/dist/native/androidSetup.js +300 -8
- package/dist/native/bridge/WebBridge.js +481 -59
- package/dist/native/bridge/constants/NativeInterfaces.js +290 -44
- package/dist/native/bridge/errors.js +250 -26
- package/dist/native/bridge/hooks/index.js +125 -1
- package/dist/native/bridge/hooks/useCamera.js +347 -9
- package/dist/native/bridge/hooks/useDataProtection.js +200 -2
- package/dist/native/bridge/hooks/useDeviceInfo.js +71 -2
- package/dist/native/bridge/hooks/useFilePicker.js +429 -2
- package/dist/native/bridge/hooks/useGoogleSignIn.js +81 -1
- package/dist/native/bridge/hooks/useHapticFeedback.js +196 -1
- package/dist/native/bridge/hooks/useIntent.js +95 -1
- package/dist/native/bridge/hooks/useNativeTransition.js +202 -27
- package/dist/native/bridge/hooks/useNetworkStatus.js +84 -3
- package/dist/native/bridge/hooks/useNotification.js +318 -2
- package/dist/native/bridge/hooks/useSafeArea.js +84 -2
- package/dist/native/bridge/hooks/useVideoStream.js +434 -6
- package/dist/native/bridge/hooks/utils.js +27 -1
- package/dist/native/bridge/hooks.js +16 -1
- package/dist/native/bridge/perf/collectors/ApiCollector.js +85 -0
- package/dist/native/bridge/perf/collectors/CacheCollector.js +215 -0
- package/dist/native/bridge/perf/collectors/HookCollector.js +165 -0
- package/dist/native/bridge/perf/collectors/InsightsCollector.js +505 -0
- package/dist/native/bridge/perf/collectors/InteractionCollector.js +273 -0
- package/dist/native/bridge/perf/collectors/KeyboardCollector.js +124 -0
- package/dist/native/bridge/perf/collectors/NavigationCollector.js +294 -0
- package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +183 -0
- package/dist/native/bridge/perf/collectors/RenderCollector.js +284 -0
- package/dist/native/bridge/perf/collectors/ScrollCollector.js +111 -0
- package/dist/native/bridge/perf/collectors/ThreadCollector.js +111 -0
- package/dist/native/bridge/perf/core/constants.js +126 -0
- package/dist/native/bridge/perf/core/measure.js +189 -0
- package/dist/native/bridge/perf/core/session.js +100 -0
- package/dist/native/bridge/perf/core/store.js +554 -0
- package/dist/native/bridge/perf/core/summary.js +174 -0
- package/dist/native/bridge/perf/core/traceExport.js +240 -0
- package/dist/native/bridge/perf/core/waterfalls.js +146 -0
- package/dist/native/bridge/perf/index.js +402 -0
- package/dist/native/bridge/safeArea.js +53 -5
- package/dist/native/bridge/standardizedHooks.js +595 -82
- package/dist/native/bridge/useBaseHook.js +274 -38
- package/dist/native/bridge/utils/CameraUtils.js +201 -56
- package/dist/native/bridge/utils/FileObjectConverter.js +129 -18
- package/dist/native/bridge/utils/NativeBridge.js +425 -141
- package/dist/native/buildAndroid/assets.js +510 -13
- package/dist/native/buildAndroid/build.js +296 -19
- package/dist/native/buildAndroid/config.js +87 -1
- package/dist/native/buildAndroid/index.js +374 -1
- package/dist/native/buildAppAndroid.js +34 -2
- package/dist/native/buildAppIos.js +62 -2
- package/dist/native/buildIos/assets.js +609 -8
- package/dist/native/buildIos/build.js +639 -20
- package/dist/native/buildIos/config.js +294 -12
- package/dist/native/buildIos/index.js +343 -6
- package/dist/native/buildIos/plugins.js +343 -16
- package/dist/native/internal-plugins/device-info-plugin/android/DeviceInfoPlugin.kt +43 -0
- package/dist/native/internal-plugins/device-info-plugin/ios/DeviceInfoPlugin.swift +28 -0
- package/dist/native/internal-plugins/device-info-plugin/manifest.json +19 -0
- package/dist/native/internalPluginUtils.js +232 -7
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
- package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +10 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +126 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +75 -1
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
- package/dist/native/package.json +3 -0
- package/dist/native/plugin-bridge/PluginBridge.js +227 -1
- package/dist/native/pluginComposerAndroid.js +407 -23
- package/dist/native/pluginComposerIos.js +435 -11
- package/dist/native/renameAndroidProject.js +1094 -88
- package/dist/native/setupEmulatorIos.js +289 -22
- package/dist/native/setupServer.js +160 -19
- package/dist/native/terminalProgress.js +199 -11
- package/dist/native/utils.js +193 -15
- package/dist/offline/catalyst-sw.js +145 -1
- package/dist/offline/registerServiceWorker.js +33 -1
- package/dist/otel.js +375 -46
- package/dist/router/ClientRouter.js +14 -2
- package/dist/router/ServerRouter.js +9 -1
- package/dist/router.js +1 -1
- package/dist/scripts/build.js +112 -3
- package/dist/scripts/checkVersion.js +3 -1
- package/dist/scripts/cleanCache.js +30 -1
- package/dist/scripts/generateOfflineManifest.js +116 -1
- package/dist/scripts/loadEnvironmentVariables.js +32 -6
- package/dist/scripts/plugins.js +309 -1
- package/dist/scripts/preServerInit.js +13 -0
- package/dist/scripts/scriptUtils.js +54 -3
- package/dist/scripts/serve.js +51 -4
- package/dist/scripts/start.js +47 -10
- package/dist/scripts/validator.js +149 -1
- package/dist/sentry.cjs +127 -0
- package/dist/sentry.js +125 -5
- package/dist/server/expressServer.js +250 -5
- package/dist/server/manifestCache.js +36 -0
- package/dist/server/renderer/ChunkExtractor.js +144 -0
- package/dist/server/renderer/document/Body.jsx +59 -0
- package/dist/server/renderer/document/FastRefresh.jsx +20 -0
- package/dist/server/renderer/document/Head.jsx +60 -0
- package/dist/server/renderer/document/index.jsx +2 -0
- package/dist/server/renderer/extract.js +149 -31
- package/dist/server/renderer/handler.jsx +433 -0
- package/dist/server/renderer/index.js +8 -1
- package/dist/server/renderer/render.js +47 -0
- package/dist/server/utils/botDetectionMiddleware.js +29 -0
- package/dist/server/utils/cjsRequire.js +10 -0
- package/dist/server/utils/userAgentUtil.js +87 -9
- package/dist/server/utils/validator.js +164 -1
- package/dist/vite/FastRefresh.jsx +20 -0
- package/dist/vite/inject-cache-key-plugin.js +276 -0
- package/dist/vite/loadCustomViteConfig.js +22 -0
- package/dist/vite/manifest-categorization-plugin.js +455 -0
- package/dist/vite/node-loader.mjs +458 -0
- package/dist/vite/scssParams.js +21 -0
- package/dist/vite/vite.config.client.js +130 -0
- package/dist/vite/vite.config.js +422 -0
- package/dist/vite/vite.config.server.js +84 -0
- package/dist/web-router/components/MetaTag.jsx +34 -0
- package/dist/web-router/components/RouterDataProvider.jsx +332 -0
- package/dist/web-router/components/Split.jsx +200 -0
- package/dist/web-router/components/SplitInview.jsx +106 -0
- package/dist/web-router/components/SsrRequestContext.jsx +14 -0
- package/dist/web-router/context.jsx +12 -0
- package/dist/web-router/hooks.jsx +72 -0
- package/dist/web-router/types/components/MetaTag.d.ts +2 -0
- package/dist/web-router/types/components/RouterDataProvider.d.ts +95 -0
- package/dist/web-router/types/context.d.ts +14 -0
- package/dist/web-router/types/hooks.d.ts +1 -0
- package/dist/web-router/types/index.d.ts +3 -0
- package/dist/web-router/types/utils/metaDataUtils.d.ts +24 -0
- package/dist/web-router/utils/metaDataUtils.jsx +119 -0
- package/mcp_v2/conversion-tasks.json +35 -17
- package/mcp_v2/knowledge-base.json +124 -13
- package/mcp_v2/lib/helpers.js +10 -0
- package/mcp_v2/mcp.js +4 -4
- package/mcp_v2/package.json +1 -0
- package/mcp_v2/tools/build.js +116 -20
- package/mcp_v2/tools/conversion.js +106 -5
- package/mcp_v2/tools/tasks.js +18 -14
- package/package.json +35 -68
- package/babel.config.js +0 -32
- package/dist/index.js +0 -1
- package/dist/scripts/devBuild.js +0 -3
- package/dist/scripts/devServe.js +0 -3
- package/dist/scripts/loadScriptsBeforeServerStarts.js +0 -4
- package/dist/scripts/registerAliases.js +0 -6
- package/dist/server/renderer/document/Body.js +0 -11
- package/dist/server/renderer/document/Head.js +0 -9
- package/dist/server/renderer/document/index.js +0 -1
- package/dist/server/renderer/handler.js +0 -47
- package/dist/server/startServer.js +0 -57
- package/dist/webpack/babel-plugins/remove-client.plugin.js +0 -5
- package/dist/webpack/babel-plugins/remove-ssr.plugin.js +0 -5
- package/dist/webpack/babel.config.client.js +0 -1
- package/dist/webpack/babel.config.ssr.js +0 -1
- package/dist/webpack/base.babel.js +0 -7
- package/dist/webpack/development.client.babel.js +0 -10
- package/dist/webpack/production.client.babel.js +0 -4
- package/dist/webpack/production.ssr.babel.js +0 -8
- package/dist/webpack/scssParams.js +0 -9
|
@@ -1,31 +1,149 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
import React from "react"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import fs from "fs"
|
|
4
|
+
|
|
5
|
+
// ── CSS: read from disk and inline as <style> ──────────────────────────
|
|
6
|
+
|
|
7
|
+
// Process-level cache — survives across requests, reset on deploy.
|
|
8
|
+
if (!process.cssFileCache) process.cssFileCache = {}
|
|
9
|
+
|
|
10
|
+
// Per-route deferred asset paths/URLs learned from past SSRs — inlined in <head> on later visits
|
|
11
|
+
// so late <style> after </body> does not re-layout already-painted content. CSS file bodies use
|
|
12
|
+
// process.cssFileCache inside readCssFromDisk (no repeat disk read).
|
|
13
|
+
if (!process.deferredAssetsByRoute) process.deferredAssetsByRoute = new Map()
|
|
14
|
+
|
|
15
|
+
const routeRecord = (routeKey) => {
|
|
16
|
+
let rec = process.deferredAssetsByRoute.get(routeKey)
|
|
17
|
+
if (!rec) {
|
|
18
|
+
rec = { css: new Set(), js: new Set() }
|
|
19
|
+
process.deferredAssetsByRoute.set(routeKey, rec)
|
|
20
|
+
}
|
|
21
|
+
return rec
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stable key for caching deferred chunks — uses the matched route pattern (e.g. "/product/:name/:id")
|
|
25
|
+
* so all pages on the same route share one entry regardless of their actual URL parameters. */
|
|
26
|
+
export const getDeferredRouteKey = (req, allMatches = []) => {
|
|
27
|
+
return allMatches?.length ? (allMatches[allMatches.length - 1]?.route?.path ?? null) : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** CSS paths (manifest-relative) previously deferred on this route — for <head> inlining. */
|
|
31
|
+
export const getCachedDeferredCssPathsForRoute = (routeKey) => {
|
|
32
|
+
if (!routeKey) return []
|
|
33
|
+
const rec = process.deferredAssetsByRoute.get(routeKey)
|
|
34
|
+
return rec ? [...rec.css] : []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Record deferred asset paths/URLs for this route. Returns CSS paths not yet on the route so they
|
|
39
|
+
* can be inlined after </body> only once; on later visits those paths are inlined in <head> instead.
|
|
40
|
+
* JS URLs are always emitted in HTML on every navigation — skipping "cached" scripts would omit modules.
|
|
41
|
+
* @returns {{ newCssPaths: string[] }}
|
|
42
|
+
*/
|
|
43
|
+
export const registerDeferredAssetsForRoute = (routeKey, { css = [], js = [] } = {}, isBot = false) => {
|
|
44
|
+
if (!routeKey) return { newCssPaths: [] }
|
|
45
|
+
const rec = routeRecord(routeKey)
|
|
46
|
+
const newCssPaths = []
|
|
47
|
+
for (const p of css) {
|
|
48
|
+
if (!p) continue
|
|
49
|
+
if (!rec.css.has(p)) newCssPaths.push(p)
|
|
50
|
+
rec.css.add(p)
|
|
51
|
+
}
|
|
52
|
+
if (!isBot) {
|
|
53
|
+
for (const url of js) {
|
|
54
|
+
if (url) rec.js.add(url)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { newCssPaths }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cached deferred script URLs for this route, excluding URLs already loaded as critical scripts.
|
|
62
|
+
* @param {string|null} routeKey - Matched route pattern.
|
|
63
|
+
* @param {Iterable<string>} excludeUrls - Critical / head script src URLs.
|
|
64
|
+
* @returns {string[]}
|
|
65
|
+
*/
|
|
66
|
+
export const getDeferredPreloadScriptUrls = (routeKey, excludeUrls = []) => {
|
|
67
|
+
if (!routeKey) return []
|
|
68
|
+
const rec = process.deferredAssetsByRoute.get(routeKey)
|
|
69
|
+
if (!rec) return []
|
|
70
|
+
const exclude = new Set(excludeUrls)
|
|
71
|
+
return [...rec.js].filter((url) => url && !exclude.has(url))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* React <link rel="modulepreload"> elements (deduped). Use before matching <script type="module">.
|
|
76
|
+
* @param {string[]} jsUrls
|
|
77
|
+
* @param {string} [keyPrefix] - Unique prefix for React keys when rendering multiple lists.
|
|
78
|
+
*/
|
|
79
|
+
export const generateModulePreloadLinkElements = (jsUrls = [], keyPrefix = "modulepreload") =>
|
|
80
|
+
[...new Set(jsUrls)].map((url, i) =>
|
|
81
|
+
React.createElement("link", {
|
|
82
|
+
key: `${keyPrefix}-${i}`,
|
|
83
|
+
rel: "modulepreload",
|
|
84
|
+
href: url,
|
|
85
|
+
fetchPriority: "high",
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read CSS files from disk and return concatenated CSS string for inlining.
|
|
91
|
+
* @param {string[]} cssPaths - Relative CSS paths (from manifest).
|
|
92
|
+
* @param {string} basePath - Build output directory on disk.
|
|
93
|
+
* @returns {string} Concatenated CSS content.
|
|
94
|
+
*/
|
|
95
|
+
export const readCssFromDisk = (cssPaths = [], basePath) => {
|
|
96
|
+
if (!cssPaths.length) return ""
|
|
97
|
+
|
|
98
|
+
const seen = new Set()
|
|
99
|
+
const chunks = []
|
|
100
|
+
|
|
101
|
+
for (const asset of cssPaths) {
|
|
102
|
+
if (!asset || seen.has(asset)) continue
|
|
103
|
+
seen.add(asset)
|
|
104
|
+
if (asset.startsWith("http")) continue
|
|
105
|
+
|
|
106
|
+
const filePath = path.isAbsolute(asset) ? asset : path.join(basePath, asset.replace(/^\/+/, ""))
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
if (!process.cssFileCache[filePath]) {
|
|
110
|
+
process.cssFileCache[filePath] = fs.readFileSync(filePath, "utf8")
|
|
111
|
+
}
|
|
112
|
+
if (process.cssFileCache[filePath]) {
|
|
113
|
+
chunks.push(process.cssFileCache[filePath])
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Silently skip unreadable assets in production
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return chunks.join("\n")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── React elements (for SSR rendering inside <Head>) ───────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* <script type="module"> React elements for JS assets.
|
|
127
|
+
*/
|
|
128
|
+
export const generateScriptElements = (jsUrls = []) =>
|
|
129
|
+
[...new Set(jsUrls)].map((url, i) =>
|
|
130
|
+
React.createElement("script", { key: `js-${i}`, type: "module", src: url })
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// ── HTML strings (for streaming injection after body via res.write) ────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* <link rel="stylesheet"> HTML strings for deferred CSS (non-blocking, after body).
|
|
137
|
+
*/
|
|
138
|
+
export const generateCssLinkStrings = (cssUrls = []) =>
|
|
139
|
+
[...new Set(cssUrls)].map((url) => `<link rel="stylesheet" href="${url}">`).join("")
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* <link rel="modulepreload"> + <script type="module"> HTML strings.
|
|
143
|
+
*/
|
|
144
|
+
export const generateScriptStrings = (jsUrls = []) =>
|
|
145
|
+
[...new Set(jsUrls)]
|
|
146
|
+
.map((url) => {
|
|
147
|
+
return `<script type="module" src="${url}"></script>`
|
|
148
|
+
})
|
|
149
|
+
.join("")
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { renderStart, renderEnd } from "./render.js"
|
|
3
|
+
import { Provider } from "react-redux"
|
|
4
|
+
import { Body } from "./document/Body.jsx"
|
|
5
|
+
import { Head } from "./document/Head.jsx"
|
|
6
|
+
|
|
7
|
+
import { StaticRouter } from "react-router-dom/server"
|
|
8
|
+
import ServerRouter from "../../router/ServerRouter.js"
|
|
9
|
+
import { renderToPipeableStream } from "react-dom/server"
|
|
10
|
+
import { getUserAgentDetails } from "../utils/userAgentUtil.js"
|
|
11
|
+
import { serverDataFetcher, matchRoutes as NestedMatchRoutes, getMetaData } from "../../index.jsx"
|
|
12
|
+
import { validateConfigureStore, validateGetRoutes, safeCall } from "../utils/validator.js"
|
|
13
|
+
import { ChunkExtractor } from "./ChunkExtractor.js"
|
|
14
|
+
import {
|
|
15
|
+
readCssFromDisk,
|
|
16
|
+
generateScriptElements,
|
|
17
|
+
generateCssLinkStrings,
|
|
18
|
+
generateScriptStrings,
|
|
19
|
+
getDeferredRouteKey,
|
|
20
|
+
getCachedDeferredCssPathsForRoute,
|
|
21
|
+
registerDeferredAssetsForRoute,
|
|
22
|
+
getDeferredPreloadScriptUrls,
|
|
23
|
+
generateModulePreloadLinkElements,
|
|
24
|
+
} from "./extract.js"
|
|
25
|
+
import path from "path"
|
|
26
|
+
|
|
27
|
+
import CustomDocument from "@catalyst/template/server/document"
|
|
28
|
+
|
|
29
|
+
import App from "@catalyst/template/src/js/containers/App/index"
|
|
30
|
+
import { getRoutes } from "@catalyst/template/src/js/routes/utils"
|
|
31
|
+
import createStore from "@catalyst/template/src/js/store/index.js"
|
|
32
|
+
import { SsrRequestProvider } from "../../web-router/components/SsrRequestContext.jsx"
|
|
33
|
+
import { getManifest, getAssetManifest } from "../manifestCache.js"
|
|
34
|
+
|
|
35
|
+
const DEFAULT_SAFE_AREA_INSETS = { top: 0, right: 0, bottom: 0, left: 0 }
|
|
36
|
+
|
|
37
|
+
const parseSafeAreaFromHeaders = (req) => {
|
|
38
|
+
const readEdge = (header) => {
|
|
39
|
+
const raw = req.get(header) ?? req.headers[header.toLowerCase()]
|
|
40
|
+
const value = Number(raw)
|
|
41
|
+
return Number.isFinite(value) && value >= 0 ? value : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const top = readEdge("X-Safe-Area-Top")
|
|
45
|
+
const right = readEdge("X-Safe-Area-Right")
|
|
46
|
+
const bottom = readEdge("X-Safe-Area-Bottom")
|
|
47
|
+
const left = readEdge("X-Safe-Area-Left")
|
|
48
|
+
|
|
49
|
+
if (![top, right, bottom, left].some((value) => value !== null)) return null
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
top: top ?? 0,
|
|
53
|
+
right: right ?? 0,
|
|
54
|
+
bottom: bottom ?? 0,
|
|
55
|
+
left: left ?? 0,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Routes are static for the lifetime of the server — resolve once and reuse
|
|
60
|
+
// the same instance per request to avoid per-request allocation.
|
|
61
|
+
let _cachedRoutes
|
|
62
|
+
const getCachedRoutes = () => {
|
|
63
|
+
if (_cachedRoutes === undefined) {
|
|
64
|
+
_cachedRoutes = validateGetRoutes(getRoutes) ? getRoutes() : null
|
|
65
|
+
}
|
|
66
|
+
return _cachedRoutes
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Try to import user-defined hooks. These are optional — apps that don't export them
|
|
70
|
+
// will get undefined, and safeCall is a no-op for non-functions.
|
|
71
|
+
let _onRouteMatch,
|
|
72
|
+
_onFetcherSuccess,
|
|
73
|
+
_onFetcherError,
|
|
74
|
+
_onAppServerSideSuccess,
|
|
75
|
+
_onAppServerSideError,
|
|
76
|
+
_onRenderError,
|
|
77
|
+
_onRequestError
|
|
78
|
+
try {
|
|
79
|
+
const hooks = await import("@catalyst/template/server/index.js")
|
|
80
|
+
_onRouteMatch = hooks.onRouteMatch
|
|
81
|
+
_onFetcherSuccess = hooks.onFetcherSuccess
|
|
82
|
+
_onFetcherError = hooks.onFetcherError
|
|
83
|
+
_onAppServerSideSuccess = hooks.onAppServerSideSuccess
|
|
84
|
+
_onAppServerSideError = hooks.onAppServerSideError
|
|
85
|
+
_onRenderError = hooks.onRenderError
|
|
86
|
+
_onRequestError = hooks.onRequestError
|
|
87
|
+
} catch {
|
|
88
|
+
// No hooks file — all hooks remain undefined, safeCall will skip them
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Passthrough no-ops used when OTEL_ENABLE is not set; replaced below if enabled.
|
|
92
|
+
let withObservability = (_service, fn) => fn
|
|
93
|
+
let withSyncObservability = (_service, fn) => fn
|
|
94
|
+
|
|
95
|
+
if (process.env.OTEL_ENABLE === true) {
|
|
96
|
+
try {
|
|
97
|
+
const otel = await import("../../otel.js")
|
|
98
|
+
withObservability = otel.withObservability
|
|
99
|
+
withSyncObservability = otel.withSyncObservability
|
|
100
|
+
} catch {
|
|
101
|
+
// otel packages not installed — continue without tracing
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const SSR_SERVICE = process.env.SERVICE_NAME || `pwa-${process.env.APPLICATION}-node-server`
|
|
106
|
+
|
|
107
|
+
const traceHook = (fn, spanName) =>
|
|
108
|
+
typeof fn === "function" ? withSyncObservability(SSR_SERVICE, fn, spanName) : fn
|
|
109
|
+
|
|
110
|
+
const onRouteMatch = traceHook(_onRouteMatch, "onRouteMatch")
|
|
111
|
+
const onFetcherSuccess = traceHook(_onFetcherSuccess, "onFetcherSuccess")
|
|
112
|
+
const onFetcherError = traceHook(_onFetcherError, "onFetcherError")
|
|
113
|
+
const onAppServerSideSuccess = traceHook(_onAppServerSideSuccess, "onAppServerSideSuccess")
|
|
114
|
+
const onAppServerSideError = traceHook(_onAppServerSideError, "onAppServerSideError")
|
|
115
|
+
const onRenderError = traceHook(_onRenderError, "onRenderError")
|
|
116
|
+
const onRequestError = traceHook(_onRequestError, "onRequestError")
|
|
117
|
+
|
|
118
|
+
// ── Asset collection ───────────────────────────────────────────────────
|
|
119
|
+
const _collectAssets = (req, allMatches) => {
|
|
120
|
+
const chunkExtractor = new ChunkExtractor({
|
|
121
|
+
manifest: getManifest() || {},
|
|
122
|
+
assetManifest: getAssetManifest() || {},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Add route-matched CSS/JS to critical bucket (loaded in <head>)
|
|
126
|
+
chunkExtractor.preloadRouteCss(allMatches)
|
|
127
|
+
|
|
128
|
+
return chunkExtractor
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const collectAssets = withSyncObservability(SSR_SERVICE, _collectAssets, "collectAssets")
|
|
132
|
+
|
|
133
|
+
// ── JSX tree ───────────────────────────────────────────────────────────
|
|
134
|
+
const getComponent = (store, context, req, fetcherData, isBot) => (
|
|
135
|
+
<div id="app">
|
|
136
|
+
<SsrRequestProvider value={{ isBot }}>
|
|
137
|
+
<Provider store={store}>
|
|
138
|
+
<StaticRouter context={context} location={req.originalUrl}>
|
|
139
|
+
<ServerRouter store={store} intialData={fetcherData} />
|
|
140
|
+
</StaticRouter>
|
|
141
|
+
</Provider>
|
|
142
|
+
</SsrRequestProvider>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// ── Render and stream ──────────────────────────────────────────────────
|
|
147
|
+
const _renderMarkUp = async (
|
|
148
|
+
errorCode,
|
|
149
|
+
req,
|
|
150
|
+
res,
|
|
151
|
+
metaTags,
|
|
152
|
+
fetcherData,
|
|
153
|
+
store,
|
|
154
|
+
allMatches,
|
|
155
|
+
context,
|
|
156
|
+
chunkExtractor
|
|
157
|
+
) => {
|
|
158
|
+
const deviceDetails = getUserAgentDetails(req.headers["user-agent"] || "")
|
|
159
|
+
// Match mweb's wider definition: synthetic monitors (StatusCake) and AI crawlers
|
|
160
|
+
// also need the bot code path — no JS, and split(... ssr: false ...) widgets that
|
|
161
|
+
// previously read state.shellReducer.isBot now read this via SsrRequestContext.
|
|
162
|
+
const isBot = !!(deviceDetails.googleBot || deviceDetails.aiBot || deviceDetails.statusCakeBot)
|
|
163
|
+
const safeArea = parseSafeAreaFromHeaders(req) || { ...DEFAULT_SAFE_AREA_INSETS }
|
|
164
|
+
const previousSafeArea = globalThis.__SAFE_AREA_INITIAL__
|
|
165
|
+
globalThis.__SAFE_AREA_INITIAL__ = safeArea
|
|
166
|
+
|
|
167
|
+
const cleanupSafeArea = () => {
|
|
168
|
+
if (previousSafeArea === undefined) {
|
|
169
|
+
delete globalThis.__SAFE_AREA_INITIAL__
|
|
170
|
+
} else {
|
|
171
|
+
globalThis.__SAFE_AREA_INITIAL__ = previousSafeArea
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Critical assets → <head>
|
|
176
|
+
const criticalAssets = chunkExtractor ? chunkExtractor.getCriticalAssets() : { js: [], css: [] }
|
|
177
|
+
|
|
178
|
+
// Inline critical CSS from disk (small thanks to natural code-splitting)
|
|
179
|
+
const buildDir = path.join(process.env.src_path, process.env.BUILD_OUTPUT_PATH || "build")
|
|
180
|
+
const inlineCss = readCssFromDisk(criticalAssets.css, buildDir)
|
|
181
|
+
|
|
182
|
+
const deferredRouteKey = getDeferredRouteKey(req, allMatches)
|
|
183
|
+
const deferredRouteInlineCss = readCssFromDisk(
|
|
184
|
+
getCachedDeferredCssPathsForRoute(deferredRouteKey),
|
|
185
|
+
buildDir
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
const jsScripts = generateScriptElements(criticalAssets.js)
|
|
189
|
+
const criticalPreloadLinks = generateModulePreloadLinkElements(criticalAssets.js, "critical-js")
|
|
190
|
+
const deferredPreloadUrls = getDeferredPreloadScriptUrls(deferredRouteKey, criticalAssets.js)
|
|
191
|
+
const deferredPreloadLinks = generateModulePreloadLinkElements(deferredPreloadUrls, "deferred-js")
|
|
192
|
+
|
|
193
|
+
// Build Head props
|
|
194
|
+
const shellStart = renderStart({
|
|
195
|
+
inlineCss,
|
|
196
|
+
deferredRouteInlineCss,
|
|
197
|
+
jsScripts,
|
|
198
|
+
criticalPreloadLinks,
|
|
199
|
+
deferredPreloadLinks,
|
|
200
|
+
metaTags,
|
|
201
|
+
isBot,
|
|
202
|
+
fetcherData,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const state = store.getState()
|
|
206
|
+
const jsx = getComponent(store, context, req, fetcherData, isBot)
|
|
207
|
+
const shellEnd = renderEnd(state, res, jsx, errorCode, fetcherData)
|
|
208
|
+
|
|
209
|
+
const finalProps = { ...shellStart, ...shellEnd, jsx, req, res, safeArea }
|
|
210
|
+
|
|
211
|
+
const CompleteDocument = () => {
|
|
212
|
+
if (CustomDocument) {
|
|
213
|
+
return CustomDocument(finalProps)
|
|
214
|
+
}
|
|
215
|
+
return (
|
|
216
|
+
<html lang={finalProps.lang}>
|
|
217
|
+
<Head
|
|
218
|
+
isBot={finalProps.isBot}
|
|
219
|
+
inlineCss={finalProps.inlineCss}
|
|
220
|
+
deferredRouteInlineCss={finalProps.deferredRouteInlineCss}
|
|
221
|
+
jsScripts={finalProps.jsScripts}
|
|
222
|
+
criticalPreloadLinks={finalProps.criticalPreloadLinks}
|
|
223
|
+
deferredPreloadLinks={finalProps.deferredPreloadLinks}
|
|
224
|
+
fetcherData={finalProps.fetcherData}
|
|
225
|
+
metaTags={finalProps.metaTags}
|
|
226
|
+
publicAssetPath={finalProps.publicAssetPath}
|
|
227
|
+
/>
|
|
228
|
+
<Body
|
|
229
|
+
initialState={finalProps.initialState}
|
|
230
|
+
jsx={finalProps.jsx}
|
|
231
|
+
statusCode={finalProps.statusCode}
|
|
232
|
+
fetcherData={finalProps.fetcherData}
|
|
233
|
+
safeArea={finalProps.safeArea}
|
|
234
|
+
/>
|
|
235
|
+
</html>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const status = errorCode || (allMatches.length && allMatches[0]?.route?.path === "*" ? 404 : 200)
|
|
241
|
+
res.set({ "content-type": "text/html; charset=utf-8" })
|
|
242
|
+
res.status(status)
|
|
243
|
+
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
const { pipe } = renderToPipeableStream(<CompleteDocument />, {
|
|
246
|
+
onShellReady() {
|
|
247
|
+
res.setHeader("content-type", "text/html")
|
|
248
|
+
pipe(res)
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
onAllReady() {
|
|
252
|
+
// Deferred assets — injected after body (non-blocking)
|
|
253
|
+
const deferredAssets = chunkExtractor
|
|
254
|
+
? chunkExtractor.getDeferredAssets()
|
|
255
|
+
: { js: [], css: [] }
|
|
256
|
+
|
|
257
|
+
// Tell client which components were SSR'd so split() can
|
|
258
|
+
// eagerly import them (prevents Suspense fallback flash)
|
|
259
|
+
res.write(`<script>window.__CATALYST_IS_BOT__=${isBot ? "true" : "false"};</script>`)
|
|
260
|
+
if (chunkExtractor) {
|
|
261
|
+
const renderedKeys = chunkExtractor.getRenderedComponentKeys()
|
|
262
|
+
res.write(
|
|
263
|
+
`<script>window.__SSR_RENDERED_COMPONENTS__=new Set(${JSON.stringify(renderedKeys)})</script>`
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { newCssPaths } = registerDeferredAssetsForRoute(
|
|
268
|
+
deferredRouteKey,
|
|
269
|
+
deferredAssets,
|
|
270
|
+
isBot
|
|
271
|
+
)
|
|
272
|
+
if (newCssPaths.length) {
|
|
273
|
+
res.write(`<style>${readCssFromDisk(newCssPaths, buildDir)}</style>`)
|
|
274
|
+
}
|
|
275
|
+
if (!isBot) {
|
|
276
|
+
res.write(generateScriptStrings(deferredAssets.js))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
res.end()
|
|
280
|
+
cleanupSafeArea()
|
|
281
|
+
resolve()
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
onError(error) {
|
|
285
|
+
console.error("Error in renderToPipeableStream:", error)
|
|
286
|
+
safeCall(onRenderError, { req, res, store, error })
|
|
287
|
+
cleanupSafeArea()
|
|
288
|
+
reject(error)
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
} catch (error) {
|
|
293
|
+
cleanupSafeArea()
|
|
294
|
+
console.error("Error in rendering document on server:", error)
|
|
295
|
+
safeCall(onRenderError, { req, res, store, error })
|
|
296
|
+
return Promise.reject(error)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const tracedRenderMarkUp = withObservability(SSR_SERVICE, _renderMarkUp, "renderMarkUp")
|
|
301
|
+
const tracedAppServerSideFunction = withObservability(
|
|
302
|
+
SSR_SERVICE,
|
|
303
|
+
(args) => App.serverSideFunction(args),
|
|
304
|
+
"App.serverSideFunction"
|
|
305
|
+
)
|
|
306
|
+
const tracedServerDataFetcher = withObservability(SSR_SERVICE, serverDataFetcher, "serverDataFetcher")
|
|
307
|
+
const tracedGetMetaData = withSyncObservability(SSR_SERVICE, getMetaData, "getMetaData")
|
|
308
|
+
|
|
309
|
+
// ── Express middleware ──────────────────────────────────────────────────
|
|
310
|
+
/**
|
|
311
|
+
* SSR request handler. Execution pipeline per request:
|
|
312
|
+
* 1. Match route → collect assets
|
|
313
|
+
* 2. App.serverSideFunction (app-level server hook)
|
|
314
|
+
* 3. serverDataFetcher (route-level data fetching)
|
|
315
|
+
* 4. renderMarkUp → renderToPipeableStream (stream to client)
|
|
316
|
+
*
|
|
317
|
+
* res.headersSent is checked after each async step: if a user hook
|
|
318
|
+
* (onRouteMatch, onFetcherSuccess, etc.) has already sent a response
|
|
319
|
+
* (e.g. a redirect), we bail out early without attempting another render.
|
|
320
|
+
*/
|
|
321
|
+
async function _handler(req, res) {
|
|
322
|
+
try {
|
|
323
|
+
let context = {}
|
|
324
|
+
let fetcherData = {}
|
|
325
|
+
const store = validateConfigureStore(createStore) ? await createStore({}, req, res) : null
|
|
326
|
+
|
|
327
|
+
const cachedRoutes = getCachedRoutes()
|
|
328
|
+
const allMatches = cachedRoutes ? NestedMatchRoutes(cachedRoutes, req.originalUrl) || [] : []
|
|
329
|
+
let allTags = []
|
|
330
|
+
|
|
331
|
+
safeCall(onRouteMatch, { req, res, matches: allMatches, store })
|
|
332
|
+
|
|
333
|
+
if (res.headersSent) return
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
await tracedAppServerSideFunction({ store, req, res })
|
|
337
|
+
safeCall(onAppServerSideSuccess, { req, res, store })
|
|
338
|
+
|
|
339
|
+
if (res.headersSent) return
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
fetcherData = await tracedServerDataFetcher(
|
|
343
|
+
{ routes: cachedRoutes, req, res, url: req.originalUrl },
|
|
344
|
+
{ store }
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if (res.headersSent) return
|
|
348
|
+
|
|
349
|
+
const err = fetcherData?.[req.originalUrl]?.error
|
|
350
|
+
allTags = tracedGetMetaData(allMatches, fetcherData)
|
|
351
|
+
const chunkExtractor = collectAssets(req, allMatches)
|
|
352
|
+
|
|
353
|
+
if (err) {
|
|
354
|
+
safeCall(onFetcherError, { req, res, store, error: err })
|
|
355
|
+
|
|
356
|
+
if (res.headersSent) return
|
|
357
|
+
|
|
358
|
+
const statusCode = err.status_code || 404
|
|
359
|
+
await tracedRenderMarkUp(
|
|
360
|
+
statusCode,
|
|
361
|
+
req,
|
|
362
|
+
res,
|
|
363
|
+
allTags,
|
|
364
|
+
fetcherData,
|
|
365
|
+
store,
|
|
366
|
+
allMatches,
|
|
367
|
+
context,
|
|
368
|
+
chunkExtractor
|
|
369
|
+
)
|
|
370
|
+
} else {
|
|
371
|
+
safeCall(onFetcherSuccess, { req, res, store })
|
|
372
|
+
|
|
373
|
+
if (res.headersSent) return
|
|
374
|
+
|
|
375
|
+
await tracedRenderMarkUp(
|
|
376
|
+
null,
|
|
377
|
+
req,
|
|
378
|
+
res,
|
|
379
|
+
allTags,
|
|
380
|
+
fetcherData,
|
|
381
|
+
store,
|
|
382
|
+
allMatches,
|
|
383
|
+
context,
|
|
384
|
+
chunkExtractor
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.error("Error in executing serverFetcher functions:", error)
|
|
389
|
+
safeCall(onFetcherError, { req, res, store, error })
|
|
390
|
+
|
|
391
|
+
if (res.headersSent) return
|
|
392
|
+
|
|
393
|
+
const chunkExtractor = collectAssets(req, allMatches)
|
|
394
|
+
await tracedRenderMarkUp(
|
|
395
|
+
404,
|
|
396
|
+
req,
|
|
397
|
+
res,
|
|
398
|
+
allTags,
|
|
399
|
+
fetcherData,
|
|
400
|
+
store,
|
|
401
|
+
allMatches,
|
|
402
|
+
context,
|
|
403
|
+
chunkExtractor
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
} catch (error) {
|
|
407
|
+
console.error("Error in executing serverSideFunction inside App:", error)
|
|
408
|
+
safeCall(onAppServerSideError, { req, res, store, error })
|
|
409
|
+
|
|
410
|
+
if (res.headersSent) return
|
|
411
|
+
|
|
412
|
+
const chunkExtractor = collectAssets(req, allMatches)
|
|
413
|
+
await tracedRenderMarkUp(
|
|
414
|
+
error.status_code,
|
|
415
|
+
req,
|
|
416
|
+
res,
|
|
417
|
+
allTags,
|
|
418
|
+
fetcherData,
|
|
419
|
+
store,
|
|
420
|
+
allMatches,
|
|
421
|
+
context,
|
|
422
|
+
chunkExtractor
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
} catch (error) {
|
|
426
|
+
console.error("Error in handling document request:", error)
|
|
427
|
+
safeCall(onRequestError, { req, res, error })
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const handler = withObservability(SSR_SERVICE, _handler, "handler")
|
|
432
|
+
|
|
433
|
+
export default handler
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import express from "express"
|
|
2
|
+
import handler from "./handler.jsx"
|
|
3
|
+
|
|
4
|
+
const router = express.Router()
|
|
5
|
+
|
|
6
|
+
router.use(handler)
|
|
7
|
+
|
|
8
|
+
export default router
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns data used by the Head component for page rendering.
|
|
3
|
+
*/
|
|
4
|
+
export const renderStart = ({
|
|
5
|
+
inlineCss,
|
|
6
|
+
deferredRouteInlineCss,
|
|
7
|
+
jsScripts,
|
|
8
|
+
criticalPreloadLinks,
|
|
9
|
+
deferredPreloadLinks,
|
|
10
|
+
metaTags,
|
|
11
|
+
isBot,
|
|
12
|
+
fetcherData,
|
|
13
|
+
}) => {
|
|
14
|
+
const { IS_DEV_COMMAND, WEBPACK_DEV_SERVER_HOSTNAME, WEBPACK_DEV_SERVER_PORT } = process.env
|
|
15
|
+
|
|
16
|
+
let publicAssetPath = `${process.env.PUBLIC_STATIC_ASSET_URL}${process.env.PUBLIC_STATIC_ASSET_PATH}`
|
|
17
|
+
|
|
18
|
+
if (JSON.parse(IS_DEV_COMMAND)) {
|
|
19
|
+
publicAssetPath = `http://${WEBPACK_DEV_SERVER_HOSTNAME}:${WEBPACK_DEV_SERVER_PORT}/assets/`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
inlineCss,
|
|
24
|
+
deferredRouteInlineCss,
|
|
25
|
+
jsScripts,
|
|
26
|
+
criticalPreloadLinks,
|
|
27
|
+
deferredPreloadLinks,
|
|
28
|
+
metaTags,
|
|
29
|
+
isBot,
|
|
30
|
+
publicAssetPath,
|
|
31
|
+
fetcherData,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns data used by the Body component for page rendering.
|
|
37
|
+
*/
|
|
38
|
+
export const renderEnd = (initialState = {}, res, jsx, errorCode, fetcherData) => {
|
|
39
|
+
return {
|
|
40
|
+
initialState,
|
|
41
|
+
firstFoldCss: "",
|
|
42
|
+
firstFoldJS: "",
|
|
43
|
+
jsx,
|
|
44
|
+
errorCode,
|
|
45
|
+
fetcherData,
|
|
46
|
+
}
|
|
47
|
+
}
|