Commit 32c13634 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Enhance OG tags with API data (#1806)

* simple fetch data for token instance page

* generate enhanced metadata

* group query params in page props

* make base gSSP function generic

* add metric for search engine bots

* measure api request duration

* get API data for token, address and dapp pages

* improve typings for apiData prop

* add ENV variable

* tweak logs for /media-type requests

* [skip ci] add prefix to default metrics
parent 79b02e11
......@@ -8,6 +8,7 @@ const meta = Object.freeze({
og: {
description: getEnvValue('NEXT_PUBLIC_OG_DESCRIPTION') || '',
imageUrl: app.baseUrl + (getExternalAssetFilePath('NEXT_PUBLIC_OG_IMAGE_URL') || defaultImageUrl),
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED') === 'true',
},
});
......
......@@ -601,6 +601,7 @@ const schema = yup
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE: yup.boolean(),
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: yup.boolean(),
NEXT_PUBLIC_SAFE_TX_SERVICE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),
......
......@@ -40,6 +40,7 @@ NEXT_PUBLIC_NETWORK_SHORT_NAME=Test
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_OG_DESCRIPTION='Hello world!'
NEXT_PUBLIC_OG_IMAGE_URL=https://example.com/image.png
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://blockscout.com','text':'Blockscout'}]
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE=true
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global
......
......@@ -85,7 +85,7 @@ frontend:
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_HAS_USER_OPS: true
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: noves
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: blockscout
NEXT_PUBLIC_SWAP_BUTTON_URL: uniswap
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS: true
NEXT_PUBLIC_AD_BANNER_PROVIDER: getit
......@@ -93,7 +93,7 @@ frontend:
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP: "{ \"id\": \"632019\", \"width\": \"728\", \"height\": \"90\" }"
NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE: "{ \"id\": \"632018\", \"width\": \"320\", \"height\": \"100\" }"
NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED: true
PROMETHEUS_METRICS_ENABLED: true
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: true
envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
......
......@@ -176,6 +176,7 @@ Settings for meta tags and OG tags
| NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE | `boolean` | Set to `true` to promote Blockscout in meta and OG titles | - | `true` | `true` |
| NEXT_PUBLIC_OG_DESCRIPTION | `string` | Custom OG description | - | - | `Blockscout is the #1 open-source blockchain explorer available today. 100+ chains and counting rely on Blockscout data availability, APIs, and ecosystem tools to support their networks.` |
| NEXT_PUBLIC_OG_IMAGE_URL | `string` | OG image url. Minimum image size is 200 x 20 pixels (recommended: 1200 x 600); maximum supported file size is 8 MB; 2:1 aspect ratio; supported formats: image/jpeg, image/gif, image/png | - | `static/og_placeholder.png` | `https://placekitten.com/1200/600` |
| NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED | `boolean` | Set to `true` to populate OG tags (title, description) with API data for social preview robot requests | - | `false` | `true` |
 
......
......@@ -15,13 +15,9 @@ import 'lib/setLocale';
const PAGE_PROPS = {
cookies: '',
referrer: '',
id: '',
height_or_hash: '',
hash: '',
number: '',
q: '',
name: '',
adBannerProvider: '',
query: {},
adBannerProvider: undefined,
apiData: null,
};
const TestApp = ({ children }: {children: React.ReactNode}) => {
......
......@@ -10,13 +10,9 @@ type Props = {
const AppContext = createContext<PageProps>({
cookies: '',
referrer: '',
id: '',
height_or_hash: '',
hash: '',
number: '',
q: '',
name: '',
adBannerProvider: '',
query: {},
adBannerProvider: undefined,
apiData: null,
});
export function AppContextProvider({ children, pageProps }: Props) {
......
......@@ -4,26 +4,29 @@ import type { Route } from 'nextjs-routes';
import generate from './generate';
interface TestCase<R extends Route> {
interface TestCase<Pathname extends Route['pathname']> {
title: string;
route: R;
apiData?: ApiData<R>;
route: {
pathname: Pathname;
query?: Route['query'];
};
apiData?: ApiData<Pathname>;
}
const TEST_CASES: Array<TestCase<Route>> = [
const TEST_CASES = [
{
title: 'static route',
route: {
pathname: '/blocks',
},
},
} as TestCase<'/blocks'>,
{
title: 'dynamic route',
route: {
pathname: '/tx/[hash]',
query: { hash: '0x12345' },
},
},
} as TestCase<'/tx/[hash]'>,
{
title: 'dynamic route with API data',
route: {
......@@ -31,7 +34,7 @@ const TEST_CASES: Array<TestCase<Route>> = [
query: { hash: '0x12345' },
},
apiData: { symbol: 'USDT' },
} as TestCase<{ pathname: '/token/[hash]'; query: { hash: string }}>,
} as TestCase<'/token/[hash]'>,
];
describe('generates correct metadata for:', () => {
......
import type { ApiData, Metadata } from './types';
import type { RouteParams } from 'nextjs/types';
import type { Route } from 'nextjs-routes';
......@@ -9,7 +10,7 @@ import compileValue from './compileValue';
import getPageOgType from './getPageOgType';
import * as templates from './templates';
export default function generate<R extends Route>(route: R, apiData?: ApiData<R>): Metadata {
export default function generate<Pathname extends Route['pathname']>(route: RouteParams<Pathname>, apiData: ApiData<Pathname> = null): Metadata {
const params = {
...route.query,
...apiData,
......@@ -17,7 +18,7 @@ export default function generate<R extends Route>(route: R, apiData?: ApiData<R>
network_title: getNetworkTitle(),
};
const compiledTitle = compileValue(templates.title.make(route.pathname), params);
const compiledTitle = compileValue(templates.title.make(route.pathname, Boolean(apiData)), params);
const title = compiledTitle ? compiledTitle + (config.meta.promoteBlockscoutInTitle ? ' | Blockscout' : '') : '';
const description = compileValue(templates.description.make(route.pathname), params);
......
import type { IncomingMessage, ServerResponse } from 'http';
import { httpLogger } from 'nextjs/utils/logger';
import metrics from 'lib/monitoring/metrics';
export default async function getApiDataForSocialPreview(req: IncomingMessage | undefined, res: ServerResponse<IncomingMessage> | undefined, pathname: string) {
if (!req || !res || !metrics) {
return;
}
const userAgent = req.headers['user-agent'];
if (!userAgent) {
return;
}
if (userAgent.toLowerCase().includes('twitter')) {
httpLogger(req, res);
metrics.requestCounter.inc({ route: pathname, bot: 'twitter' });
}
if (userAgent.toLowerCase().includes('facebook')) {
httpLogger(req, res);
metrics.requestCounter.inc({ route: pathname, bot: 'facebook' });
}
if (userAgent.toLowerCase().includes('telegram')) {
httpLogger(req, res);
metrics.requestCounter.inc({ route: pathname, bot: 'telegram' });
}
}
export { default as generate } from './generate';
export { default as update } from './update';
export * from './types';
......@@ -13,10 +13,10 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/contract-verification': 'verify contract',
'/address/[hash]/contract-verification': 'contract verification for %hash%',
'/tokens': 'tokens',
'/token/[hash]': '%symbol% token details',
'/token/[hash]': 'token details',
'/token/[hash]/instance/[id]': 'NFT instance',
'/apps': 'apps marketplace',
'/apps/[id]': '- %app_name%',
'/apps/[id]': 'marketplace app',
'/stats': 'statistics',
'/api-docs': 'REST API',
'/graphiql': 'GraphQL',
......@@ -56,8 +56,15 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/auth/unverified-email': 'unverified email',
};
export function make(pathname: Route['pathname']) {
const template = TEMPLATE_MAP[pathname];
const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
'/token/[hash]': '%symbol% token details',
'/token/[hash]/instance/[id]': 'token instance for %symbol%',
'/apps/[id]': '- %app_name%',
'/address/[hash]': 'address details for %domain_name%',
};
export function make(pathname: Route['pathname'], isEnriched = false) {
const template = (isEnriched ? TEMPLATE_MAP_ENHANCED[pathname] : undefined) ?? TEMPLATE_MAP[pathname];
return `%network_name% ${ template }`;
}
import type { Route } from 'nextjs-routes';
/* eslint-disable @typescript-eslint/indent */
export type ApiData<R extends Route> =
R['pathname'] extends '/token/[hash]' ? { symbol: string } :
R['pathname'] extends '/token/[hash]/instance/[id]' ? { symbol: string } :
R['pathname'] extends '/apps/[id]' ? { app_name: string } :
never;
export type ApiData<Pathname extends Route['pathname']> =
(
Pathname extends '/address/[hash]' ? { domain_name: string } :
Pathname extends '/token/[hash]' ? { symbol: string } :
Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } :
Pathname extends '/apps/[id]' ? { app_name: string } :
never
) | null;
export interface Metadata {
title: string;
......
import type { ApiData } from './types';
import type { RouteParams } from 'nextjs/types';
import type { Route } from 'nextjs-routes';
import generate from './generate';
export default function update<R extends Route>(route: R, apiData: ApiData<R>) {
export default function update<Pathname extends Route['pathname']>(route: RouteParams<Pathname>, apiData: ApiData<Pathname>) {
const { title, description } = generate(route, apiData);
window.document.title = title;
......
......@@ -8,13 +8,26 @@ const metrics = (() => {
promClient.register.clear();
const requestCounter = new promClient.Counter({
name: 'request_counter',
help: 'Number of incoming requests',
const socialPreviewBotRequests = new promClient.Counter({
name: 'social_preview_bot_requests_total',
help: 'Number of incoming requests from social preview bots',
labelNames: [ 'route', 'bot' ] as const,
});
return { requestCounter };
const searchEngineBotRequests = new promClient.Counter({
name: 'search_engine_bot_requests_total',
help: 'Number of incoming requests from search engine bots',
labelNames: [ 'route', 'bot' ] as const,
});
const apiRequestDuration = new promClient.Histogram({
name: 'api_request_duration_seconds',
help: 'Duration of requests to API in seconds',
labelNames: [ 'route', 'code' ],
buckets: [ 0.2, 0.5, 1, 3, 10 ],
});
return { socialPreviewBotRequests, searchEngineBotRequests, apiRequestDuration };
})();
export default metrics;
......@@ -2,6 +2,7 @@ import Head from 'next/head';
import React from 'react';
import type { Route } from 'nextjs-routes';
import type { Props as PageProps } from 'nextjs/getServerSideProps';
import config from 'configs/app';
import useAdblockDetect from 'lib/hooks/useAdblockDetect';
......@@ -10,14 +11,17 @@ import * as metadata from 'lib/metadata';
import * as mixpanel from 'lib/mixpanel';
import { init as initSentry } from 'lib/sentry/config';
type Props = Route & {
interface Props<Pathname extends Route['pathname']> {
pathname: Pathname;
children: React.ReactNode;
query?: PageProps<Pathname>['query'];
apiData?: PageProps<Pathname>['apiData'];
}
initSentry();
const PageNextJs = (props: Props) => {
const { title, description, opengraph } = metadata.generate(props);
const PageNextJs = <Pathname extends Route['pathname']>(props: Props<Pathname>) => {
const { title, description, opengraph } = metadata.generate(props, props.apiData);
useGetCsrfToken();
useAdblockDetect();
......
import type { GetServerSideProps } from 'next';
import type { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
import type { AdBannerProviders } from 'types/client/adProviders';
import type { Route } from 'nextjs-routes';
import config from 'configs/app';
import isNeedProxy from 'lib/api/isNeedProxy';
const rollupFeature = config.features.rollup;
const adBannerFeature = config.features.adsBanner;
import type * as metadata from 'lib/metadata';
export type Props = {
export interface Props<Pathname extends Route['pathname'] = never> {
query: Route['query'];
cookies: string;
referrer: string;
id: string;
height_or_hash: string;
hash: string;
number: string;
q: string;
name: string;
adBannerProvider: string;
adBannerProvider: AdBannerProviders | undefined;
// if apiData is undefined, Next.js will complain that it is not serializable
// so we force it to be always present in the props but it can be null
apiData: metadata.ApiData<Pathname> | null;
}
export const base: GetServerSideProps<Props> = async({ req, query }) => {
export const base = async <Pathname extends Route['pathname'] = never>({ req, query }: GetServerSidePropsContext):
Promise<GetServerSidePropsResult<Props<Pathname>>> => {
const adBannerProvider = (() => {
if (adBannerFeature.isEnabled) {
if ('additionalProvider' in adBannerFeature && adBannerFeature.additionalProvider) {
......@@ -28,20 +32,16 @@ export const base: GetServerSideProps<Props> = async({ req, query }) => {
return adBannerFeature.provider;
}
}
return '';
return;
})();
return {
props: {
query,
cookies: req.headers.cookie || '',
referrer: req.headers.referer || '',
id: query.id?.toString() || '',
hash: query.hash?.toString() || '',
height_or_hash: query.height_or_hash?.toString() || '',
number: query.number?.toString() || '',
q: query.q?.toString() || '',
name: query.name?.toString() || '',
adBannerProvider,
apiData: null,
},
};
};
......@@ -119,14 +119,15 @@ export const batch: GetServerSideProps<Props> = async(context) => {
return base(context);
};
export const marketplace: GetServerSideProps<Props> = async(context) => {
export const marketplace = async <Pathname extends Route['pathname'] = never>(context: GetServerSidePropsContext):
Promise<GetServerSidePropsResult<Props<Pathname>>> => {
if (!config.features.marketplace.isEnabled) {
return {
notFound: true,
};
}
return base(context);
return base<Pathname>(context);
};
export const apiDocs: GetServerSideProps<Props> = async(context) => {
......
import type { NextPage } from 'next';
import type { Route } from 'nextjs-routes';
// eslint-disable-next-line @typescript-eslint/ban-types
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: React.ReactElement) => React.ReactNode;
}
export interface RouteParams<Pathname extends Route['pathname']> {
pathname: Pathname;
query?: Route['query'];
}
import type { IncomingMessage } from 'http';
type SocialPreviewBot = 'twitter' | 'facebook' | 'telegram' | 'slack';
type SearchEngineBot = 'google' | 'bing' | 'yahoo' | 'duckduckgo';
type ReturnType = {
type: 'social_preview';
bot: SocialPreviewBot;
} | {
type: 'search_engine';
bot: SearchEngineBot;
} | undefined
export default function detectBotRequest(req: IncomingMessage): ReturnType {
const userAgent = req.headers['user-agent'];
if (!userAgent) {
return;
}
if (userAgent.toLowerCase().includes('twitter')) {
return { type: 'social_preview', bot: 'twitter' };
}
if (userAgent.toLowerCase().includes('facebook')) {
return { type: 'social_preview', bot: 'facebook' };
}
if (userAgent.toLowerCase().includes('telegram')) {
return { type: 'social_preview', bot: 'telegram' };
}
if (userAgent.toLowerCase().includes('slack')) {
return { type: 'social_preview', bot: 'slack' };
}
if (userAgent.toLowerCase().includes('googlebot')) {
return { type: 'search_engine', bot: 'google' };
}
if (userAgent.toLowerCase().includes('bingbot')) {
return { type: 'search_engine', bot: 'bing' };
}
if (userAgent.toLowerCase().includes('yahoo')) {
return { type: 'search_engine', bot: 'yahoo' };
}
if (userAgent.toLowerCase().includes('duckduck')) {
return { type: 'search_engine', bot: 'duckduckgo' };
}
}
import fetch, { AbortError } from 'node-fetch';
import buildUrl from 'nextjs/utils/buildUrl';
import { httpLogger } from 'nextjs/utils/logger';
import { RESOURCES } from 'lib/api/resources';
import type { ResourceName, ResourcePathParams, ResourcePayload } from 'lib/api/resources';
import { SECOND } from 'lib/consts';
import metrics from 'lib/monitoring/metrics';
type Params<R extends ResourceName> = (
{
resource: R;
pathParams?: ResourcePathParams<R>;
} | {
url: string;
route: string;
}
) & {
timeout?: number;
}
export default async function fetchApi<R extends ResourceName = never, S = ResourcePayload<R>>(params: Params<R>): Promise<S | undefined> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, params.timeout || SECOND);
const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams);
const route = 'route' in params ? params.route : RESOURCES[params.resource]['path'];
const end = metrics?.apiRequestDuration.startTimer();
try {
const response = await fetch(url, { signal: controller.signal });
const duration = end?.({ route, code: response.status });
if (response.status === 200) {
httpLogger.logger.info({ message: 'API fetch', url, code: response.status, duration });
} else {
httpLogger.logger.error({ message: 'API fetch', url, code: response.status, duration });
}
return await response.json() as Promise<S>;
} catch (error) {
const code = error instanceof AbortError ? 504 : 500;
const duration = end?.({ route, code });
httpLogger.logger.error({ message: 'API fetch', url, code, duration });
} finally {
clearTimeout(timeout);
}
}
......@@ -30,14 +30,9 @@ export default function fetchFactory(
};
httpLogger.logger.info({
message: 'Trying to call API',
message: 'API fetch via Next.js proxy',
url,
req: _req,
});
httpLogger.logger.info({
message: 'API request headers',
headers,
// headers,
});
const body = (() => {
......
import type { IncomingMessage, ServerResponse } from 'http';
import metrics from 'lib/monitoring/metrics';
import detectBotRequest from './detectBotRequest';
export default async function logRequestFromBot(req: IncomingMessage | undefined, res: ServerResponse<IncomingMessage> | undefined, pathname: string) {
if (!req || !res || !metrics) {
return;
}
const botInfo = detectBotRequest(req);
if (!botInfo) {
return;
}
switch (botInfo.type) {
case 'search_engine': {
metrics.searchEngineBotRequests.inc({ route: pathname, bot: botInfo.bot });
return;
}
case 'social_preview': {
metrics.socialPreviewBotRequests.inc({ route: pathname, bot: botInfo.bot });
return;
}
}
}
......@@ -3,9 +3,9 @@ import type { DocumentContext } from 'next/document';
import Document, { Html, Head, Main, NextScript } from 'next/document';
import React from 'react';
import logRequestFromBot from 'nextjs/utils/logRequestFromBot';
import * as serverTiming from 'nextjs/utils/serverTiming';
import getApiDataForSocialPreview from 'lib/metadata/getApiDataForSocialPreview';
import theme from 'theme';
import * as svgSprite from 'ui/shared/IconSvg';
......@@ -22,7 +22,7 @@ class MyDocument extends Document {
return result;
};
await getApiDataForSocialPreview(ctx.req, ctx.res, ctx.pathname);
await logRequestFromBot(ctx.req, ctx.res, ctx.pathname);
const initialProps = await Document.getInitialProps(ctx);
......
......@@ -8,7 +8,7 @@ import ContractVerificationForAddress from 'ui/pages/ContractVerificationForAddr
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/address/[hash]/contract-verification" query={ props }>
<PageNextJs pathname="/address/[hash]/contract-verification" query={ props.query }>
<ContractVerificationForAddress/>
</PageNextJs>
);
......
import type { NextPage } from 'next';
import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Route } from 'nextjs-routes';
import type { Props } from 'nextjs/getServerSideProps';
import * as gSSP from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import detectBotRequest from 'nextjs/utils/detectBotRequest';
import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
const Address = dynamic(() => import('ui/pages/Address'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
const pathname: Route['pathname'] = '/address/[hash]';
const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/address/[hash]" query={ props }>
<PageNextJs pathname="/address/[hash]" query={ props.query } apiData={ props.apiData }>
<Address/>
</PageNextJs>
);
......@@ -17,4 +25,24 @@ const Page: NextPage<Props> = (props: Props) => {
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
const addressData = await fetchApi({
resource: 'address',
pathParams: { hash: getQueryParamString(ctx.query.hash) },
timeout: 1_000,
});
(await baseResponse.props).apiData = addressData && addressData.ens_domain_name ? {
domain_name: addressData.ens_domain_name,
} : null;
}
}
return baseResponse;
};
import type { NextApiRequest, NextApiResponse } from 'next';
import buildUrl from 'nextjs/utils/buildUrl';
import fetchFactory from 'nextjs/utils/fetch';
import fetchFactory from 'nextjs/utils/fetchProxy';
import { httpLogger } from 'nextjs/utils/logger';
export default async function csrfHandler(_req: NextApiRequest, res: NextApiResponse) {
......
......@@ -3,16 +3,20 @@ import nodeFetch from 'node-fetch';
import { httpLogger } from 'nextjs/utils/logger';
import metrics from 'lib/monitoring/metrics';
import getQueryParamString from 'lib/router/getQueryParamString';
export default async function mediaTypeHandler(req: NextApiRequest, res: NextApiResponse) {
httpLogger(req, res);
try {
const url = getQueryParamString(req.query.url);
const end = metrics?.apiRequestDuration.startTimer();
const response = await nodeFetch(url, { method: 'HEAD' });
const duration = end?.({ route: '/media-type', code: response.status });
if (response.status !== 200) {
httpLogger.logger.error({ message: 'API fetch', url, code: response.status, duration });
throw new Error();
}
......@@ -30,6 +34,8 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi
return 'html';
}
})();
httpLogger.logger.info({ message: 'API fetch', url, code: response.status, duration });
res.status(200).json({ type: mediaType });
} catch (error) {
res.status(200).json({ type: undefined });
......
......@@ -4,7 +4,7 @@ import * as promClient from 'prom-client';
// eslint-disable-next-line no-restricted-properties
const isEnabled = process.env.PROMETHEUS_METRICS_ENABLED === 'true';
isEnabled && promClient.collectDefaultMetrics();
isEnabled && promClient.collectDefaultMetrics({ prefix: 'frontend_' });
export default async function metricsHandler(req: NextApiRequest, res: NextApiResponse) {
const metrics = await promClient.register.metrics();
......
......@@ -2,7 +2,7 @@ import _pick from 'lodash/pick';
import _pickBy from 'lodash/pickBy';
import type { NextApiRequest, NextApiResponse } from 'next';
import fetchFactory from 'nextjs/utils/fetch';
import fetchFactory from 'nextjs/utils/fetchProxy';
import appConfig from 'configs/app';
......
import type { GetServerSideProps } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { NextPageWithLayout } from 'nextjs/types';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import type { Route } from 'nextjs-routes';
import * as gSSP from 'nextjs/getServerSideProps';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import detectBotRequest from 'nextjs/utils/detectBotRequest';
import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
import LayoutApp from 'ui/shared/layout/LayoutApp';
const MarketplaceApp = dynamic(() => import('ui/pages/MarketplaceApp'), { ssr: false });
const Page: NextPageWithLayout<Props> = (props: Props) => {
const pathname: Route['pathname'] = '/apps/[id]';
const feature = config.features.marketplace;
const Page: NextPageWithLayout<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/apps/[id]" query={ props }>
<PageNextJs pathname="/apps/[id]" query={ props.query } apiData={ props.apiData }>
<MarketplaceApp/>
</PageNextJs>
);
......@@ -28,4 +39,40 @@ Page.getLayout = function getLayout(page: React.ReactElement) {
export default Page;
export { marketplace as getServerSideProps } from 'nextjs/getServerSideProps';
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.marketplace<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse && feature.isEnabled) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
const appData = await(async() => {
if ('configUrl' in feature) {
const appList = await fetchApi<never, Array<MarketplaceAppOverview>>({
url: config.app.baseUrl + feature.configUrl,
route: '/marketplace_config',
timeout: 1_000,
});
if (appList && Array.isArray(appList)) {
return appList.find(app => app.id === getQueryParamString(ctx.query.id));
}
} else {
return await fetchApi({
resource: 'marketplace_dapp',
pathParams: { dappId: getQueryParamString(ctx.query.id), chainId: config.chain.id },
timeout: 1_000,
});
}
})();
(await baseResponse.props).apiData = appData && appData.title ? {
app_name: appData.title,
} : null;
}
}
return baseResponse;
};
......@@ -25,7 +25,7 @@ const Batch = dynamic(() => {
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/batches/[number]" query={ props }>
<PageNextJs pathname="/batches/[number]" query={ props.query }>
<Batch/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const Blob = dynamic(() => import('ui/pages/Blob'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/blobs/[hash]" query={ props }>
<PageNextJs pathname="/blobs/[hash]" query={ props.query }>
<Blob/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const Block = dynamic(() => import('ui/pages/Block'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/block/[height_or_hash]" query={ props }>
<PageNextJs pathname="/block/[height_or_hash]" query={ props.query }>
<Block/>
</PageNextJs>
);
......
......@@ -8,7 +8,7 @@ import ContractVerification from 'ui/pages/ContractVerification';
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/contract-verification" query={ props }>
<PageNextJs pathname="/contract-verification" query={ props.query }>
<ContractVerification/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const NameDomain = dynamic(() => import('ui/pages/NameDomain'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/name-domains/[name]" query={ props }>
<PageNextJs pathname="/name-domains/[name]" query={ props.query }>
<NameDomain/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const UserOp = dynamic(() => import('ui/pages/UserOp'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/op/[hash]" query={ props }>
<PageNextJs pathname="/op/[hash]" query={ props.query }>
<UserOp/>
</PageNextJs>
);
......
......@@ -12,7 +12,7 @@ const SearchResults = dynamic(() => import('ui/pages/SearchResults'), { ssr: fal
const Page: NextPageWithLayout<Props> = (props: Props) => {
return (
<PageNextJs pathname="/search-results" query={ props }>
<PageNextJs pathname="/search-results" query={ props.query }>
<SearchResults/>
</PageNextJs>
);
......
import type { NextPage } from 'next';
import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Route } from 'nextjs-routes';
import type { Props } from 'nextjs/getServerSideProps';
import * as gSSP from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import detectBotRequest from 'nextjs/utils/detectBotRequest';
import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
const Token = dynamic(() => import('ui/pages/Token'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
const pathname: Route['pathname'] = '/token/[hash]';
const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/token/[hash]" query={ props }>
<PageNextJs pathname="/token/[hash]" query={ props.query } apiData={ props.apiData }>
<Token/>
</PageNextJs>
);
......@@ -17,4 +26,24 @@ const Page: NextPage<Props> = (props: Props) => {
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
const tokenData = await fetchApi({
resource: 'token',
pathParams: { hash: getQueryParamString(ctx.query.hash) },
timeout: 1_000,
});
(await baseResponse.props).apiData = tokenData && tokenData.symbol ? {
symbol: tokenData.symbol,
} : null;
}
}
return baseResponse;
};
import type { NextPage } from 'next';
import type { GetServerSideProps, NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Route } from 'nextjs-routes';
import type { Props } from 'nextjs/getServerSideProps';
import * as gSSP from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import detectBotRequest from 'nextjs/utils/detectBotRequest';
import fetchApi from 'nextjs/utils/fetchApi';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
const TokenInstance = dynamic(() => import('ui/pages/TokenInstance'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
const pathname: Route['pathname'] = '/token/[hash]/instance/[id]';
const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => {
return (
<PageNextJs pathname="/token/[hash]/instance/[id]" query={ props }>
<PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }>
<TokenInstance/>
</PageNextJs>
);
......@@ -17,4 +26,24 @@ const Page: NextPage<Props> = (props: Props) => {
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
const baseResponse = await gSSP.base<typeof pathname>(ctx);
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
const botInfo = detectBotRequest(ctx.req);
if (botInfo?.type === 'social_preview') {
const tokenData = await fetchApi({
resource: 'token',
pathParams: { hash: getQueryParamString(ctx.query.hash) },
timeout: 1_000,
});
(await baseResponse.props).apiData = tokenData && tokenData.symbol ? {
symbol: tokenData.symbol,
} : null;
}
}
return baseResponse;
};
......@@ -9,7 +9,7 @@ const Transaction = dynamic(() => import('ui/pages/Transaction'), { ssr: false }
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/tx/[hash]" query={ props }>
<PageNextJs pathname="/tx/[hash]" query={ props.query }>
<Transaction/>
</PageNextJs>
);
......
......@@ -9,7 +9,7 @@ const KettleTxs = dynamic(() => import('ui/pages/KettleTxs'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/txs/kettle/[hash]" query={ props }>
<PageNextJs pathname="/txs/kettle/[hash]" query={ props.query }>
<KettleTxs/>
</PageNextJs>
);
......
......@@ -24,13 +24,9 @@ const defaultAppContext = {
pageProps: {
cookies: '',
referrer: '',
id: '',
height_or_hash: '',
hash: '',
number: '',
q: '',
name: '',
adBannerProvider: 'slise',
query: {},
adBannerProvider: 'slise' as const,
apiData: null,
},
};
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment