From afe3f414c3db9ffe1f4499019a911d7d5442afe2 Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 24 Aug 2026 17:12:13 +0300 Subject: [PATCH 1/6] feat(ssr): added metrics --- .../shared/models/meta-tags/meta-tags-data.model.ts | 1 + src/app/shared/services/meta-tags-builder.service.ts | 5 +++++ src/app/shared/services/meta-tags.service.ts | 1 + src/server.ts | 11 +++++++++++ 4 files changed, 18 insertions(+) diff --git a/src/app/shared/models/meta-tags/meta-tags-data.model.ts b/src/app/shared/models/meta-tags/meta-tags-data.model.ts index aa86ec61a..6e3362b31 100644 --- a/src/app/shared/models/meta-tags/meta-tags-data.model.ts +++ b/src/app/shared/models/meta-tags/meta-tags-data.model.ts @@ -8,6 +8,7 @@ export interface MetaTagsData { osfGuid?: string | null; title?: DataContent; type?: DataContent; + osfType?: DataContent; description?: DataContent; url?: DataContent; canonicalUrl?: DataContent; diff --git a/src/app/shared/services/meta-tags-builder.service.ts b/src/app/shared/services/meta-tags-builder.service.ts index 31e8f8db7..00e21dc1c 100644 --- a/src/app/shared/services/meta-tags-builder.service.ts +++ b/src/app/shared/services/meta-tags-builder.service.ts @@ -9,6 +9,7 @@ import { PreprintModel } from '@osf/features/preprints/models'; import { ProjectOverviewModel } from '@osf/features/project/overview/models'; import { RegistrationOverviewModel } from '@osf/features/registry/models'; +import { CurrentResourceType } from '../enums/resource-type.enum'; import { pathJoin } from '../helpers/path-join.helper'; import { ContributorModel } from '../models/contributors/contributor.model'; import { FileDetailsModel } from '../models/files/file.model'; @@ -35,6 +36,7 @@ export class MetaTagsBuilderService { return { osfGuid: project.id, + osfType: CurrentResourceType.Projects, title: project.title, description: project.description, url: pathJoin(this.environment.webUrl, project.id, 'overview'), @@ -61,6 +63,7 @@ export class MetaTagsBuilderService { return { osfGuid: registry.id, + osfType: CurrentResourceType.Registrations, title: registry.title, description: registry.description, publishedDate: this.formatDate(registry.dateRegistered), @@ -85,6 +88,7 @@ export class MetaTagsBuilderService { return { osfGuid: preprint?.id, + osfType: CurrentResourceType.Preprints, title: preprint?.title, description: preprint?.description, publishedDate: this.formatDate(preprint?.datePublished), @@ -111,6 +115,7 @@ export class MetaTagsBuilderService { return { osfGuid: file.guid, + osfType: CurrentResourceType.Files, title: fileMetadata?.title || file.name, type: fileMetadata?.resourceTypeGeneral, description: fileMetadata?.description ?? this.translateService.instant('files.metaTagDescriptionPlaceholder'), diff --git a/src/app/shared/services/meta-tags.service.ts b/src/app/shared/services/meta-tags.service.ts index 83b2ca1f8..cb048cf28 100644 --- a/src/app/shared/services/meta-tags.service.ts +++ b/src/app/shared/services/meta-tags.service.ts @@ -158,6 +158,7 @@ export class MetaTagsService { citation_description: metaTagsData.description, citation_public_url: metaTagsData.url, citation_publication_date: metaTagsData.publishedDate, + 'osf:type': metaTagsData.osfType, // Dublin Core 'dct.title': metaTagsData.title, diff --git a/src/server.ts b/src/server.ts index 7cb802f4c..bd9ba3690 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,14 @@ const angularApp = new AngularNodeAppEngine({ const isBot = (ua: string) => /bot|googlebot|crawler|spider|robot|crawling/i.test(ua); +const getContentTypeFromHtml = (html: string): string | null => { + const byNameFirst = html.match(/]*name=["']osf:type["'][^>]*content=["']([^"']+)["'][^>]*>/i); + if (byNameFirst?.[1]) return byNameFirst[1]; + + const byContentFirst = html.match(/]*content=["']([^"']+)["'][^>]*name=["']osf:type["'][^>]*>/i); + return byContentFirst?.[1] ?? null; +}; + app.use( express.static(browserDistFolder, { maxAge: '1y', @@ -56,6 +64,7 @@ app.use((req, res, next) => { setImmediate(async () => { try { let isComplete = true; + let contentType: string | null = null; const shouldSample = isSearchBot || Math.random() < 0.05; @@ -66,10 +75,12 @@ app.use((req, res, next) => { const hasAppRootClosed = html.includes(''); const isEmptyApp = html.includes(''); isComplete = hasTitle && hasAppRootClosed && !isEmptyApp; + contentType = getContentTypeFromHtml(html); } const body = { url: req.originalUrl, + contentType, status: response.status, ttfb: Math.round(ttfb), isBot: isSearchBot, From 61fdf918450ab489a70b51d3090b8e889cbd70dd Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 1 Sep 2026 16:42:27 +0300 Subject: [PATCH 2/6] feat(ssr): added logic to send metrics to api --- src/app/app.config.server.ts | 57 +++++++-------- src/app/app.routes.server.ts | 58 +++++++++++++-- src/server.ts | 88 +++-------------------- src/server/ssr-html-metrics.ts | 73 +++++++++++++++++++ src/server/ssr-metrics.middleware.ts | 103 +++++++++++++++++++++++++++ src/server/ssr-metrics.model.ts | 20 ++++++ src/server/ssr-metrics.ts | 39 ++++++++++ src/server/ssr-server-config.ts | 27 +++++++ src/server/static-cache-headers.ts | 9 +++ 9 files changed, 357 insertions(+), 117 deletions(-) create mode 100644 src/server/ssr-html-metrics.ts create mode 100644 src/server/ssr-metrics.middleware.ts create mode 100644 src/server/ssr-metrics.model.ts create mode 100644 src/server/ssr-metrics.ts create mode 100644 src/server/ssr-server-config.ts create mode 100644 src/server/static-cache-headers.ts diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts index fe0a21ea7..f5d6e9e1c 100644 --- a/src/app/app.config.server.ts +++ b/src/app/app.config.server.ts @@ -1,4 +1,4 @@ -import { provideTranslateLoader, TranslateLoader } from '@ngx-translate/core'; +import { provideTranslateLoader, TranslateLoader, TranslationObject } from '@ngx-translate/core'; import { Observable, of } from 'rxjs'; @@ -8,56 +8,51 @@ import { provideServerRendering, withRoutes } from '@angular/ssr'; import { SSR_CONFIG } from '@core/constants/ssr-config.token'; import { ConfigModel } from '@core/models/config.model'; +import { readJsonFile } from '../server/ssr-server-config'; + import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; -import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +const serverDistFolder = dirname(fileURLToPath(import.meta.url)); +const configPath = resolve(serverDistFolder, '../browser/assets/config/config.json'); +const i18nFolder = resolve(serverDistFolder, '../browser/assets/i18n'); +const ssrConfig = { + ...readJsonFile(configPath, {} as ConfigModel), + throttleToken: process.env['THROTTLE_TOKEN'] || '', +} as ConfigModel; + +const SSR_LANGUAGES = ['en'] as const; +const supportedLanguages = new Set(SSR_LANGUAGES); +const translationCache = new Map(); + +translationCache.set(SSR_LANGUAGES[0], readJsonFile(resolve(i18nFolder, `${SSR_LANGUAGES[0]}.json`), {})); + class SsrFsTranslateLoader implements TranslateLoader { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getTranslation(lang: string): Observable { - const serverDistFolder = dirname(fileURLToPath(import.meta.url)); - const translationPath = resolve(serverDistFolder, `../browser/assets/i18n/${lang}.json`); + getTranslation(lang: string): Observable { + const cached = translationCache.get(lang); - if (!existsSync(translationPath)) { - return of({}); + if (cached) { + return of(cached); } - try { - return of(JSON.parse(readFileSync(translationPath, 'utf-8'))); - } catch { + if (!supportedLanguages.has(lang)) { return of({}); } - } -} - -function loadSsrConfig(): ConfigModel { - const serverDistFolder = dirname(fileURLToPath(import.meta.url)); - const configPath = resolve(serverDistFolder, '../browser/assets/config/config.json'); - - let config = {} as ConfigModel; - if (existsSync(configPath)) { - try { - config = JSON.parse(readFileSync(configPath, 'utf-8')); - } catch { - config = {} as ConfigModel; - } + const translation = readJsonFile(resolve(i18nFolder, `${lang}.json`), {}); + translationCache.set(lang, translation); + return of(translation); } - - return { - ...config, - throttleToken: process.env['THROTTLE_TOKEN'] || '', - } as ConfigModel; } const serverConfig: ApplicationConfig = { providers: [ provideServerRendering(withRoutes(serverRoutes)), provideTranslateLoader(SsrFsTranslateLoader), - { provide: SSR_CONFIG, useFactory: loadSsrConfig }, + { provide: SSR_CONFIG, useValue: ssrConfig }, ], }; diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 89fea762c..f22a1b74e 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -62,23 +62,47 @@ export const serverRoutes: ServerRoute[] = [ renderMode: RenderMode.Client, }, { - path: 'request-access/:id', + path: 'search', renderMode: RenderMode.Server, }, { - path: 'resetpassword/:userId/:token', - renderMode: RenderMode.Server, + path: 'spam-content', + renderMode: RenderMode.Client, }, { - path: 'search', - renderMode: RenderMode.Server, + path: 'preprints/select', + renderMode: RenderMode.Client, }, { - path: 'preprints/discover', - renderMode: RenderMode.Server, + path: 'preprints/my-reviewing', + renderMode: RenderMode.Client, + }, + { + path: 'preprints/:providerId/submit', + renderMode: RenderMode.Client, + }, + { + path: 'preprints/:providerId/edit/**', + renderMode: RenderMode.Client, + }, + { + path: 'preprints/:providerId/moderation/**', + renderMode: RenderMode.Client, + }, + { + path: 'preprints/:providerId/new-version/**', + renderMode: RenderMode.Client, }, { path: 'preprints/:providerId/:id/pending-moderation', + renderMode: RenderMode.Client, + }, + { + path: 'preprints/:providerId/:id/download', + renderMode: RenderMode.Client, + }, + { + path: 'preprints/discover', renderMode: RenderMode.Server, }, { @@ -93,10 +117,22 @@ export const serverRoutes: ServerRoute[] = [ path: 'preprints/:providerId', renderMode: RenderMode.Server, }, + { + path: 'registries/my-registrations', + renderMode: RenderMode.Client, + }, + { + path: 'registries/:providerId/moderation/**', + renderMode: RenderMode.Client, + }, { path: 'registries/discover', renderMode: RenderMode.Server, }, + { + path: 'registries/:providerId/discover', + renderMode: RenderMode.Server, + }, { path: 'registries/:providerId', renderMode: RenderMode.Server, @@ -121,6 +157,10 @@ export const serverRoutes: ServerRoute[] = [ path: 'user/:id', renderMode: RenderMode.Server, }, + { + path: ':id/files/:fileGuid/preview', + renderMode: RenderMode.Server, + }, { path: 'project/:id/node/:nodeId/files/:provider/:fileId', renderMode: RenderMode.Server, @@ -153,6 +193,10 @@ export const serverRoutes: ServerRoute[] = [ path: ':id/registrations', renderMode: RenderMode.Server, }, + { + path: ':id/analytics/**', + renderMode: RenderMode.Server, + }, { path: ':id/analytics', renderMode: RenderMode.Server, diff --git a/src/server.ts b/src/server.ts index bd9ba3690..5e08bdb11 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,8 @@ -import { - AngularNodeAppEngine, - createNodeRequestHandler, - isMainModule, - writeResponseToNodeResponse, -} from '@angular/ssr/node'; +import { AngularNodeAppEngine, createNodeRequestHandler, isMainModule } from '@angular/ssr/node'; + +import { createSsrMetricsMiddleware } from './server/ssr-metrics.middleware'; +import { loadSsrServerConfig } from './server/ssr-server-config'; +import { setStaticCacheHeaders } from './server/static-cache-headers'; import express from 'express'; import { dirname, resolve } from 'node:path'; @@ -11,93 +10,24 @@ import { fileURLToPath } from 'node:url'; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); +const configPath = resolve(browserDistFolder, 'assets/config/config.json'); const app = express(); const angularApp = new AngularNodeAppEngine({ trustProxyHeaders: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-prefix'], }); - -const isBot = (ua: string) => /bot|googlebot|crawler|spider|robot|crawling/i.test(ua); - -const getContentTypeFromHtml = (html: string): string | null => { - const byNameFirst = html.match(/]*name=["']osf:type["'][^>]*content=["']([^"']+)["'][^>]*>/i); - if (byNameFirst?.[1]) return byNameFirst[1]; - - const byContentFirst = html.match(/]*content=["']([^"']+)["'][^>]*name=["']osf:type["'][^>]*>/i); - return byContentFirst?.[1] ?? null; -}; +const serverConfig = loadSsrServerConfig(configPath); app.use( express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false, + setHeaders: setStaticCacheHeaders, }) ); -app.use((req, res, next) => { - const startTime = performance.now(); - const userAgent = req.headers['user-agent'] || ''; - const isSearchBot = isBot(userAgent); - const url = req.originalUrl; - - const isStaticFile = url.includes('/static/') || url.includes('/assets/'); - const isSystemFile = url.includes('.well-known') || url.endsWith('.json') || url.endsWith('.ico'); - - if (isStaticFile || isSystemFile) { - return angularApp - .handle(req) - .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) - .catch(next); - } - - return angularApp - .handle(req) - .then((response) => { - if (!response) return next(); - - const ttfb = performance.now() - startTime; - - const responseForUser = response.clone(); - writeResponseToNodeResponse(responseForUser, res); - - setImmediate(async () => { - try { - let isComplete = true; - let contentType: string | null = null; - - const shouldSample = isSearchBot || Math.random() < 0.05; - - if (shouldSample && response.status === 200) { - const html = await response.text(); - - const hasTitle = html.includes(''); - const isEmptyApp = html.includes(''); - isComplete = hasTitle && hasAppRootClosed && !isEmptyApp; - contentType = getContentTypeFromHtml(html); - } - - const body = { - url: req.originalUrl, - contentType, - status: response.status, - ttfb: Math.round(ttfb), - isBot: isSearchBot, - isComplete: isComplete, - timestamp: new Date().toISOString(), - }; - - // eslint-disable-next-line no-console - console.log(body); - } catch (err) { - // eslint-disable-next-line no-console - console.error(err); - } - }); - }) - .catch(next); -}); +app.use(createSsrMetricsMiddleware(angularApp, serverConfig)); if (isMainModule(import.meta.url)) { const port = process.env['PORT'] || 4000; diff --git a/src/server/ssr-html-metrics.ts b/src/server/ssr-html-metrics.ts new file mode 100644 index 000000000..198bbc6d5 --- /dev/null +++ b/src/server/ssr-html-metrics.ts @@ -0,0 +1,73 @@ +import { SsrHtmlInspection } from './ssr-metrics.model'; + +export const shouldInspectHtml = (isSearchBot: boolean, status: number) => isSearchBot && status === 200; + +const META_OPTIONAL_EXACT = new Set([ + '/search', + '/preprints/discover', + '/registries/discover', + '/terms-of-use', + '/privacy-policy', + '/choose-repository', + '/forbidden', + '/not-found', + '/forgotpassword', +]); + +const META_OPTIONAL_PREFIXES = ['/meetings', '/institutions', '/user', '/collections']; + +const isMetaOptionalPath = (path: string) => { + const pathname = path.split('?')[0].replace(/\/$/, '') || '/'; + + if (META_OPTIONAL_EXACT.has(pathname)) { + return true; + } + + if (META_OPTIONAL_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) { + return true; + } + + const segments = pathname.split('/').filter(Boolean); + + if (segments[0] === 'preprints' || segments[0] === 'registries') { + if (segments.length === 2) { + return true; + } + + if (segments.length === 3 && segments[2] === 'discover') { + return true; + } + } + + return false; +}; + +const getContentType = (html: string) => { + const match = + html.match(/]*name=["']osf:type["'][^>]*content=["']([^"']+)["']/i) ?? + html.match(/]*content=["']([^"']+)["'][^>]*name=["']osf:type["']/i); + + return match?.[1] ?? null; +}; + +export const inspectSsrHtml = (html: string, path: string): SsrHtmlInspection => { + const contentType = getContentType(html); + const isSsr = /]*ng-server-context/i.test(html); + const rootContent = html.match(/]*>([\s\S]*?)<\/osf-root>/i)?.[1]?.replace(/\s+/g, '') ?? ''; + const hasContent = rootContent.length > 0; + const hasMeta = html.includes('osf-dynamic-meta'); + + if (!isSsr) { + return { isComplete: false, contentType: null }; + } + + if (!hasContent) { + return { isComplete: false, contentType }; + } + + if (hasMeta || isMetaOptionalPath(path)) { + return { isComplete: true, contentType }; + } + + return { isComplete: false, contentType }; +}; diff --git a/src/server/ssr-metrics.middleware.ts b/src/server/ssr-metrics.middleware.ts new file mode 100644 index 000000000..73e31acc8 --- /dev/null +++ b/src/server/ssr-metrics.middleware.ts @@ -0,0 +1,103 @@ +import { AngularNodeAppEngine, writeResponseToNodeResponse } from '@angular/ssr/node'; + +import { inspectSsrHtml, shouldInspectHtml } from './ssr-html-metrics'; +import { sendSsrMetrics } from './ssr-metrics'; +import { SsrServerEnvironment } from './ssr-server-config'; + +import { NextFunction, Request, Response as ExpressResponse } from 'express'; + +const SEARCH_BOT = + /bot|crawler|spider|robot|crawling|googlebot|bingbot|yandex|baidu|duckduckbot|facebookexternalhit|facebot|whatsapp|twitterbot|linkedinbot|slackbot|telegrambot|discordbot|pinterest|embedly|meta-externalagent/i; + +const isSearchBot = (userAgent: string) => SEARCH_BOT.test(userAgent); + +const isNonMetricsRequest = (path: string) => { + const pathname = path.split('?')[0]; + + return ( + /^\/(?:static|assets)(?:\/|$)/.test(pathname) || + /^\/\.well-known(?:\/|$)/.test(pathname) || + /\.(?:json|ico|js|css|mjs|map|woff2?|ttf|eot|svg|png|jpe?g|gif|webp|txt|xml)$/i.test(pathname) + ); +}; + +const buildMetricUrl = (originalUrl: string, webUrl: string) => { + const pathname = originalUrl.split('?')[0]; + const path = pathname.startsWith('/') ? pathname : `/${pathname}`; + + if (!webUrl) return path; + + return `${webUrl.replace(/\/$/, '')}${path}`; +}; + +const queueSsrMetrics = ( + config: SsrServerEnvironment, + req: Request, + userAgent: string, + isBot: boolean, + ttfb: number, + status: number, + inspectHtml: boolean, + response?: globalThis.Response | null +) => { + setImmediate(async () => { + try { + let isComplete = false; + let contentType: string | null = null; + + if (inspectHtml && response) { + const html = await response.text(); + const inspection = inspectSsrHtml(html, req.path); + isComplete = inspection.isComplete; + contentType = inspection.contentType; + } + + await sendSsrMetrics(config, { + url: buildMetricUrl(req.originalUrl, config.webUrl), + ttfb: Math.round(ttfb), + is_bot: isBot, + is_complete: isComplete, + content_type: contentType, + status, + user_agent: userAgent, + }); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } + }); +}; + +export const createSsrMetricsMiddleware = + (angularApp: AngularNodeAppEngine, config: SsrServerEnvironment) => + (req: Request, res: ExpressResponse, next: NextFunction) => { + const startTime = performance.now(); + const userAgent = req.headers['user-agent'] || ''; + const bot = isSearchBot(userAgent); + + if (isNonMetricsRequest(req.path)) { + return angularApp + .handle(req) + .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) + .catch(next); + } + + return angularApp + .handle(req) + .then((response) => { + if (!response) { + queueSsrMetrics(config, req, userAgent, bot, performance.now() - startTime, 0, false); + return next(); + } + + const ttfb = performance.now() - startTime; + const inspectHtml = shouldInspectHtml(bot, response.status); + + queueSsrMetrics(config, req, userAgent, bot, ttfb, response.status, inspectHtml, response); + return writeResponseToNodeResponse(inspectHtml ? response.clone() : response, res); + }) + .catch((err) => { + queueSsrMetrics(config, req, userAgent, bot, performance.now() - startTime, 500, false); + next(err); + }); + }; diff --git a/src/server/ssr-metrics.model.ts b/src/server/ssr-metrics.model.ts new file mode 100644 index 000000000..56b629ff5 --- /dev/null +++ b/src/server/ssr-metrics.model.ts @@ -0,0 +1,20 @@ +export interface SsrMetricAttributes { + url: string; + ttfb: number; + is_bot: boolean; + is_complete: boolean; + content_type: string | null; + status: number; + user_agent: string; +} + +export interface SsrMetricsPayload { + data: { + attributes: SsrMetricAttributes; + }; +} + +export interface SsrHtmlInspection { + isComplete: boolean; + contentType: string | null; +} diff --git a/src/server/ssr-metrics.ts b/src/server/ssr-metrics.ts new file mode 100644 index 000000000..00e487624 --- /dev/null +++ b/src/server/ssr-metrics.ts @@ -0,0 +1,39 @@ +import { SsrMetricAttributes, SsrMetricsPayload } from './ssr-metrics.model'; +import { SsrServerEnvironment } from './ssr-server-config'; + +const MAX_USER_AGENT_LENGTH = 512; + +const truncateUserAgent = (userAgent: string) => + userAgent.length <= MAX_USER_AGENT_LENGTH ? userAgent : userAgent.slice(0, MAX_USER_AGENT_LENGTH); + +export const sendSsrMetrics = async (config: SsrServerEnvironment, attributes: SsrMetricAttributes) => { + if (!config.apiDomainUrl) return; + + const headers: Record = { + Accept: 'application/vnd.api+json;version=2.20', + 'Content-Type': 'application/vnd.api+json', + }; + + if (config.throttleToken) { + headers['X-Throttle-Token'] = config.throttleToken; + } + + const payload: SsrMetricsPayload = { + data: { + attributes: { + ...attributes, + user_agent: truncateUserAgent(attributes.user_agent), + }, + }, + }; + + const response = await fetch(`${config.apiDomainUrl}/_/metrics/events/ssr_metrics/`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`SSR metrics request failed with status ${response.status}`); + } +}; diff --git a/src/server/ssr-server-config.ts b/src/server/ssr-server-config.ts new file mode 100644 index 000000000..b63764fab --- /dev/null +++ b/src/server/ssr-server-config.ts @@ -0,0 +1,27 @@ +import { EnvironmentModel } from '@osf/shared/models/environment.model'; + +import { existsSync, readFileSync } from 'node:fs'; + +export type SsrServerEnvironment = Pick; + +export const readJsonFile = (path: string, fallback: T): T => { + if (!existsSync(path)) { + return fallback; + } + + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T; + } catch { + return fallback; + } +}; + +export const loadSsrServerConfig = (configPath: string): SsrServerEnvironment => { + const config = readJsonFile(configPath, {} as { apiDomainUrl?: string; webUrl?: string }); + + return { + apiDomainUrl: config.apiDomainUrl || process.env['API_DOMAIN_URL'] || '', + webUrl: config.webUrl || process.env['WEB_URL'] || '', + throttleToken: process.env['THROTTLE_TOKEN'] || '', + }; +}; diff --git a/src/server/static-cache-headers.ts b/src/server/static-cache-headers.ts new file mode 100644 index 000000000..090f420ad --- /dev/null +++ b/src/server/static-cache-headers.ts @@ -0,0 +1,9 @@ +import { Response } from 'express'; + +const HASHED_ASSET = /-[a-zA-Z0-9]{8,}\.(?:js|css|mjs)$/; + +export const setStaticCacheHeaders = (res: Response, filePath: string) => { + if (!HASHED_ASSET.test(filePath)) { + res.setHeader('Cache-Control', 'no-cache'); + } +}; From d6b43e40f8ee39620b3c8f7ab60a17321c33c9c0 Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 2 Sep 2026 11:57:12 +0300 Subject: [PATCH 3/6] feat(ssr): added ssr docs --- README.md | 8 ++- docs/arch.md | 6 ++ docs/ssr-metrics.md | 146 ++++++++++++++++++++++++++++++++++++++ docs/ssr.md | 169 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 docs/ssr-metrics.md create mode 100644 docs/ssr.md diff --git a/README.md b/README.md index 2960168c5..a52c62e89 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,14 @@ take up to 60 seconds once the docker build finishes. ### Recommended - [Docker Commands](docs/docker.md). +- [Architecture](docs/arch.md). - [ESLint Strategy](docs/eslint.md). - [Git Conventions](docs/git-convention.md). - [i18n](docs/i18n.md). +- [Models conventions](docs/models.md). - [NGXS Conventions](docs/ngxs.md). +- [SSR](docs/ssr.md). +- [SSR metrics](docs/ssr-metrics.md). - [Testing Strategy](docs/testing.md). ### Optional @@ -59,6 +63,6 @@ Install Volta from [volta](https://volta.sh/) and it will automatically pin Node ## Configuration -OSF uses an `assets/config/config.json` file for any 3rd-party tokens. This file is not committed to the repo. +OSF uses `src/assets/config/config.json` for third-party tokens and environment URLs. This file is not committed to the repo. -There is a `assets/config/template.json` file that can be copied to `assets/config/config.json` to store any 3rd-party tokens locally. +Copy `src/assets/config/template.json` to `src/assets/config/config.json` for local development. At runtime the app loads it from `/assets/config/config.json`. diff --git a/docs/arch.md b/docs/arch.md index e6f34845c..bc2e32a4f 100644 --- a/docs/arch.md +++ b/docs/arch.md @@ -79,6 +79,12 @@ See [NGXS State Management](./ngxs.md). --- +## SSR + +Server-side rendering, route render modes, config, and bot traffic are documented in [SSR](./ssr.md). Render metrics are in [SSR metrics](./ssr-metrics.md). + +--- + ## πŸš€ Dynamic File Generation (Schematics) Use Angular CLI for scaffolding: diff --git a/docs/ssr-metrics.md b/docs/ssr-metrics.md new file mode 100644 index 000000000..b7f77c8b4 --- /dev/null +++ b/docs/ssr-metrics.md @@ -0,0 +1,146 @@ +# SSR metrics + +## Index + +- [Overview](#overview) +- [What we track](#what-we-track) +- [When a metric is sent](#when-a-metric-is-sent) +- [When HTML is inspected](#when-html-is-inspected) +- [is_complete rules](#is_complete-rules) +- [content_type](#content_type) +- [Payload and API](#payload-and-api) +- [Related docs](#related-docs) + +--- + +## Overview + +After the SSR server sends HTML to a crawler, it POSTs a JSON:API metric to the OSF API. Collection runs in the background and does not delay the response. + +Implementation: `src/server/ssr-metrics.middleware.ts`, `src/server/ssr-metrics.ts`, `src/server/ssr-html-metrics.ts`. + +In production, the SSR Node process receives **bot traffic only**. Metrics are emitted for HTML navigations on that server. + +--- + +## What we track + +| Goal | Field | Notes | +| ------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Render success rate | `status` | Derive success as HTTP 2xx. Failures use `0` (no Angular response) or `500` (render exception). | +| Render speed | `ttfb` | Milliseconds from request start until Angular returns a response. Server-side render time, not browser network TTFB. | +| Page completeness | `is_complete` | Checked from rendered HTML. See [is_complete rules](#is_complete-rules). | +| Content type | `content_type` | API resource type from `osf:type` meta (`nodes`, `registrations`, `preprints`, `files`). Null when not set or not inspected. | +| Bot vs other | `is_bot` | From User-Agent regex in middleware. | +| Crawler identity | `user_agent` | Truncated to 512 characters. | +| Page | `url` | Full public URL (`webUrl` + path). **Query string is stripped** (no `view_only` or other params). | + +We do **not** track Search Console index status. β€œPages by content type” in SSR means **successful bot responses grouped by `content_type`**, not confirmed Google indexing. + +--- + +## When a metric is sent + +Sent for **HTML navigations** handled by the metrics middleware. + +**Skipped** (no metric): + +- `/assets/*`, `/static/*` +- `/.well-known/*` +- Static file extensions (`.js`, `.css`, `.ico`, `.json`, images, fonts, …) + +**Included**: + +- Successful renders (any HTTP status Angular returns) +- Missing Angular response β†’ `status: 0` +- Render exception β†’ `status: 500` + +--- + +## When HTML is inspected + +HTML is read only when: + +- User-Agent matches the bot regex (`SEARCH_BOT` in middleware) +- HTTP status is **200** + +If not inspected, `is_complete` stays `false` and `content_type` stays `null` even when the page rendered successfully. + +On a bot-only SSR host, every 200 could be inspected; today the code also requires a bot regex match. + +Inspection runs **after** the response is sent. It clones the response body only when inspection will run. + +--- + +## is_complete rules + +Implemented in `inspectSsrHtml` (`src/server/ssr-html-metrics.ts`). + +All must pass: + +1. **SSR marker** β€” `` has `ng-server-context` (page was server-rendered, not a bare CSR shell). +2. **Non-empty root** β€” inner HTML of `` is not empty after whitespace is removed. +3. **Meta tags or allowlisted path** β€” either: + - HTML contains `osf-dynamic-meta`, or + - path is on the meta-optional allowlist (search, discover, terms, user, institutions, meetings, collections, provider landing pages, etc.) + +--- + +## content_type + +Read from rendered HTML: + +```html + +``` + +Set by `MetaTagsService` when features build meta tags via `MetaTagsBuilderService`. + +**Populated for:** projects, registrations, preprints, files. + +**Often null for:** users, institutions, meetings, collections, search, discover, and any page without `osf:type`. That is expected if those types are out of scope for the metric. + +--- + +## Payload and API + +**Endpoint** + +``` +POST {apiDomainUrl}/_/metrics/events/ssr_metrics/ +``` + +**Headers** + +- `Accept: application/vnd.api+json;version=2.20` +- `Content-Type: application/vnd.api+json` +- `X-Throttle-Token: {THROTTLE_TOKEN}` when env is set + +**Example body** + +```json +{ + "data": { + "attributes": { + "url": "https://osf.io/abc12/overview", + "ttfb": 842, + "is_bot": true, + "is_complete": true, + "content_type": "nodes", + "status": 200, + "user_agent": "Mozilla/5.0 (compatible; Googlebot/2.1; ...)" + } + } +} +``` + +If `apiDomainUrl` is missing, the POST is skipped silently. + +Failed POSTs are logged with `console.error` in the middleware catch block. + +--- + +## Related docs + +- [SSR overview](./ssr.md) β€” architecture, routes, config +- [Architecture](./arch.md) β€” file layout diff --git a/docs/ssr.md b/docs/ssr.md new file mode 100644 index 000000000..93deb4d09 --- /dev/null +++ b/docs/ssr.md @@ -0,0 +1,169 @@ +# SSR (Server-Side Rendering) + +## Index + +- [Overview](#overview) +- [Production traffic](#production-traffic) +- [Request flow](#request-flow) +- [Key files](#key-files) +- [Render modes](#render-modes) +- [Configuration](#configuration) +- [SEO-related app behavior](#seo-related-app-behavior) +- [Local development](#local-development) +- [Adding or changing SSR routes](#adding-or-changing-ssr-routes) +- [Related docs](#related-docs) + +--- + +## Overview + +OSF uses Angular SSR so search-engine crawlers receive fully rendered HTML for public pages (projects, registrations, preprints, discover pages, and similar). + +The SSR stack has two layers: + +1. **Angular SSR** β€” renders the app on the server (`app.config.server.ts`, `app.routes.server.ts`). +2. **Express server** β€” serves static assets, delegates HTML to Angular, and sends render metrics after the response (`src/server.ts`, `src/server/*`). + +In production, **only bot traffic** is routed to the SSR build. Human traffic uses the client-only build. See [Production traffic](#production-traffic). + +--- + +## Production traffic + +``` +Crawler β†’ cloud (bot detection) β†’ SSR Node server β†’ HTML + metrics +Browser β†’ cloud β†’ static/CSR build β†’ SPA shell + client render +``` + +--- + +## Request flow + +1. Request hits Express (`src/server.ts`). +2. Static files (`/assets`, hashed JS/CSS, etc.) are served from `browser/` when possible. +3. HTML navigations go through `createSsrMetricsMiddleware` (`src/server/ssr-metrics.middleware.ts`). +4. Angular SSR renders the page (`AngularNodeAppEngine.handle`). +5. HTML is sent to the client immediately. +6. In the background (`setImmediate`): + - for bot + HTTP 200: HTML is inspected for completeness and `content_type` + - a metric payload is POSTed to the OSF API + +Bot timing is not blocked by steps 6. See [SSR metrics](./ssr-metrics.md). + +--- + +## Key files + +| File | Role | +| ---------------------------------------------- | ------------------------------------------------------ | +| `src/server.ts` | Express entry, static files, wires metrics middleware | +| `src/main.server.ts` | Angular server bootstrap | +| `src/app/app.config.server.ts` | Server providers: routes, config, i18n loader | +| `src/app/app.routes.server.ts` | Per-route render mode (Server / Client / Prerender) | +| `src/server/ssr-metrics.middleware.ts` | Render timing, HTML inspection trigger, metric queue | +| `src/server/ssr-html-metrics.ts` | `is_complete` and `content_type` checks | +| `src/server/ssr-metrics.ts` | POST metric payload to API | +| `src/server/ssr-server-config.ts` | Load `config.json` + env for metrics | +| `src/server/static-cache-headers.ts` | Cache headers for static assets | +| `src/app/shared/services/meta-tags.service.ts` | Dynamic SEO meta tags (`osf:type`, `osf-dynamic-meta`) | + +--- + +## Render modes + +Defined in `src/app/app.routes.server.ts`: + +| Mode | When to use | Bot receives | +| ------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------- | +| **Server** | Public pages that should be indexed (project overview, preprint detail, discover, …) | Full SSR HTML | +| **Client** | Auth, forms, dashboards, moderation, editors | CSR shell; metrics may report `is_complete: false` | +| **Prerender** | Static legal/help pages (`terms-of-use`, `privacy-policy`, …) | Pre-built HTML at build time | + +**Rules of thumb** + +- Public read-only detail or listing page β†’ **Server** +- Login, submit, edit, settings, β€œmy …” pages β†’ **Client** +- Fixed copy that never changes at runtime β†’ **Prerender** + +More specific routes must appear **before** broader patterns (e.g. `preprints/:providerId/:id` before `preprints/:providerId`). Unmatched paths fall through to `**` β†’ **Client**. + +--- + +## Configuration + +### `assets/config/config.json` + +Copied from `assets/config/template.json` for local dev. Deployed with the build. Used for URLs and third-party keys. + +Relevant to SSR / metrics: + +| Field | Used by | +| -------------- | ------------------------------------------------------------------- | +| `apiDomainUrl` | SSR API calls (via `OSFConfigService`) and metrics POST target host | +| `webUrl` | Canonical URLs in meta tags; full URL in metric payloads | + +### Environment variables (SSR Node process) + +| Variable | Used by | +| ---------------- | ----------------------------------------------------------------------- | +| `THROTTLE_TOKEN` | `X-Throttle-Token` on SSR API calls (auth interceptor) and metrics POST | +| `API_DOMAIN_URL` | Fallback if `apiDomainUrl` missing from `config.json` | +| `WEB_URL` | Fallback if `webUrl` missing from `config.json` | +| `PORT` | Express listen port (default `4000`) | + +**Throttle token:** SSR page-render API calls read `THROTTLE_TOKEN` from the process environment only (`app.config.server.ts`). It is not taken from `config.json` for auth. Metrics use the same env var via `loadSsrServerConfig`. + +### Angular SSR config loading + +On the server, `OSFConfigService` does not HTTP-fetch `config.json`. It uses `SSR_CONFIG`, populated at startup from disk in `app.config.server.ts`. + +Translations on SSR are loaded from `browser/assets/i18n/en.json` (cached at module load). Browser builds use the HTTP loader as usual. + +--- + +## SEO-related app behavior + +### Meta tags + +`MetaTagsService` writes dynamic tags with class `osf-dynamic-meta`. Resource pages also emit `osf:type` (API type: `nodes`, `registrations`, `preprints`, `files`). Metrics read `osf:type` from the rendered HTML for `content_type`. + +Built in `MetaTagsBuilderService` for project, registration, preprint, and file pages. + +--- + +## Local development + +| Command | What it does | +| ----------------------- | --------------------------------------------- | +| `npm start` | CSR dev server (port 4200) | +| `npm run start:ssr` | Dev server with SSR (`dev-ssr` configuration) | +| `npm run build:ssr` | Production SSR build β†’ `dist/osf/` | +| `npm run serve:ssr:osf` | Run built Express server (port 4000) | + +Typical local SSR test: + +```bash +npm run build:ssr +npm run serve:ssr:osf +``` + +Set `THROTTLE_TOKEN` in the shell if SSR API calls should bypass throttling locally. + +Docker `start:docker` runs the **development** configuration (CSR), not the SSR server. Use `build:ssr` + `serve:ssr:osf` or the Dockerfile `ssr` stage for SSR. + +--- + +## Adding or changing SSR routes + +1. Add the route in `app.routes.ts` (browser routes). +2. Add a matching entry in `app.routes.server.ts` with the correct `RenderMode`. +3. If the page should contribute SEO meta, ensure the feature calls `MetaTagsService.updateMetaTags` and sets `osfType` where applicable. +4. For public indexable pages, prefer **Server**. Do not SSR authenticated workflows unless there is a specific SEO need. + +--- + +## Related docs + +- [SSR metrics](./ssr-metrics.md) β€” payload, `is_complete`, dashboards +- [Architecture](./arch.md) β€” folder layout +- [Docker](./docker.md) β€” container workflows From a863104e1d899f68212fe14fc8a213243b557887 Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 2 Sep 2026 12:44:11 +0300 Subject: [PATCH 4/6] fix(packages): fixed one vulnerability --- package-lock.json | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 763db2879..395232425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6141,9 +6141,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6262,9 +6262,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -6282,11 +6282,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -6430,9 +6430,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -7134,9 +7134,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.383", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.383.tgz", - "integrity": "sha512-I2484/KkAvl8lm9VyjH2JnbOIV0d/UCqT7gbzs6l+o6Vmn9wgB66uVcKX+Vk6HrXtY6fbWTOEXuv8waDTuFNCw==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -9955,9 +9955,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -12263,9 +12263,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { From 6f08a1a15b0eb166a30aecc7cde03cd1053a4717 Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 2 Sep 2026 18:46:32 +0300 Subject: [PATCH 5/6] feat(profile): added meta tags for profile --- .../profile/profile.component.spec.ts | 147 +++++++++++++----- src/app/features/profile/profile.component.ts | 20 ++- .../shared/services/meta-tags.service.spec.ts | 45 ++++-- src/app/shared/services/meta-tags.service.ts | 24 ++- 4 files changed, 170 insertions(+), 66 deletions(-) diff --git a/src/app/features/profile/profile.component.spec.ts b/src/app/features/profile/profile.component.spec.ts index 6480bbd9a..556984dd5 100644 --- a/src/app/features/profile/profile.component.spec.ts +++ b/src/app/features/profile/profile.component.spec.ts @@ -1,81 +1,152 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { UserSelectors } from '@core/store/user'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { InstitutionsSelectors } from '@shared/stores/institutions'; +import { MOCK_USER } from '@testing/mocks/data.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { MetaTagsServiceMockFactory } from '@testing/providers/meta-tags.service.mock'; +import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-ready.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; import { ProfileInformationComponent } from './components'; import { ProfileComponent } from './profile.component'; import { ProfileSelectors } from './store'; -describe('ProfileComponent', () => { - let component: ProfileComponent; - let fixture: ComponentFixture; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; - - beforeEach(() => { - routerMock = RouterMockBuilder.create().build(); - activatedRouteMock = ActivatedRouteMockBuilder.create().build(); - - TestBed.configureTestingModule({ - imports: [ - ProfileComponent, - ...MockComponents(ProfileInformationComponent, GlobalSearchComponent, LoadingSpinnerComponent), - ], - providers: [ - provideOSFCore(), - MockProvider(Router, routerMock), - MockProvider(ActivatedRoute, activatedRouteMock), - MockProvider(PrerenderReadyService), - provideMockStore({ - signals: [ - { selector: UserSelectors.getCurrentUser, value: null }, - { selector: ProfileSelectors.getUserProfile, value: null }, - { selector: ProfileSelectors.isUserProfileLoading, value: false }, - ], - }), - ], - }); - - fixture = TestBed.createComponent(ProfileComponent); - component = fixture.componentInstance; +function setup(overrides: BaseSetupOverrides = {}) { + const routerMock = RouterMockBuilder.create().build(); + const activatedRouteMock = ActivatedRouteMockBuilder.create() + .withParams(overrides.routeParams ?? {}) + .build(); + const metaTagsService = MetaTagsServiceMockFactory(); + const prerenderReadyService = PrerenderReadyServiceMockFactory(); + + const defaultSignals: SignalOverride[] = [ + { selector: UserSelectors.getCurrentUser, value: null }, + { selector: ProfileSelectors.getUserProfile, value: null }, + { selector: ProfileSelectors.isUserProfileLoading, value: false }, + { selector: InstitutionsSelectors.getUserInstitutions, value: [] }, + ]; + + TestBed.configureTestingModule({ + imports: [ + ProfileComponent, + ...MockComponents(ProfileInformationComponent, GlobalSearchComponent, LoadingSpinnerComponent), + ], + providers: [ + provideOSFCore(), + MockProvider(Router, routerMock), + MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(MetaTagsService, metaTagsService), + MockProvider(PrerenderReadyService, prerenderReadyService), + provideMockStore({ + signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides), + }), + ], }); + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(ProfileComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + return { + component, + fixture, + store, + routerMock, + activatedRouteMock, + metaTagsService, + prerenderReadyService, + }; +} + +describe('ProfileComponent', () => { it('should create', () => { + const { component } = setup(); + expect(component).toBeTruthy(); }); it('should navigate to settings/profile when called', () => { + const { component, routerMock } = setup(); + component.toProfileSettings(); expect(routerMock.navigate).toHaveBeenCalledWith(['settings/profile']); }); it('should return true when route has no id param', () => { - activatedRouteMock.snapshot!.params = {}; + const { component } = setup(); expect(component.isMyProfile()).toBe(true); }); it('should return false when route has id param', () => { - activatedRouteMock.snapshot!.params = { id: 'user456' }; + const { component } = setup({ routeParams: { id: 'user456' } }); expect(component.isMyProfile()).toBe(false); }); it('should filter out Agent resource type from search tab options', () => { - expect(component.resourceTabOptions).toBeDefined(); + const { component } = setup(); + expect(component.resourceTabOptions.every((option) => option.value !== ResourceType.Agent)).toBe(true); }); + + it('should set prerender not ready on init', () => { + const { prerenderReadyService } = setup(); + + expect(prerenderReadyService.setNotReady).toHaveBeenCalled(); + }); + + it('should update user osf type meta tag for my profile', () => { + const { metaTagsService } = setup({ + selectorOverrides: [{ selector: UserSelectors.getCurrentUser, value: MOCK_USER }], + }); + + expect(metaTagsService.updateMetaTags).toHaveBeenCalledWith( + { osfType: CurrentResourceType.Users }, + expect.anything(), + { mergeDefaults: false } + ); + expect(metaTagsService.updateMetaTags).toHaveBeenCalledTimes(1); + }); + + it('should update user osf type meta tag after fetching a public profile', () => { + const { metaTagsService } = setup({ + routeParams: { id: MOCK_USER.id }, + selectorOverrides: [{ selector: ProfileSelectors.getUserProfile, value: MOCK_USER }], + }); + + expect(metaTagsService.updateMetaTags).toHaveBeenCalledWith( + { osfType: CurrentResourceType.Users }, + expect.anything(), + { mergeDefaults: false } + ); + expect(metaTagsService.updateMetaTags).toHaveBeenCalledTimes(1); + }); + + it('should not update meta tags when my profile has no current user', () => { + const { metaTagsService } = setup(); + + expect(metaTagsService.updateMetaTags).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/profile/profile.component.ts b/src/app/features/profile/profile.component.ts index 907ca38d7..e7c98bd56 100644 --- a/src/app/features/profile/profile.component.ts +++ b/src/app/features/profile/profile.component.ts @@ -22,8 +22,9 @@ import { UserSelectors } from '@core/store/user'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SEARCH_TAB_OPTIONS } from '@osf/shared/constants/search-tab-options.const'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; import { UserModel } from '@osf/shared/models/user/user.model'; +import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; import { SetDefaultFilterValue } from '@osf/shared/stores/global-search'; import { FetchUserInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; @@ -38,10 +39,11 @@ import { FetchUserProfile, ProfileSelectors, SetUserProfile } from './store'; imports: [ProfileInformationComponent, GlobalSearchComponent, LoadingSpinnerComponent, Message, TranslatePipe], }) export class ProfileComponent implements OnInit, OnDestroy { - private router = inject(Router); - private route = inject(ActivatedRoute); - private destroyRef = inject(DestroyRef); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); private readonly prerenderReady = inject(PrerenderReadyService); + private readonly metaTags = inject(MetaTagsService); private actions = createDispatchMap({ fetchUserProfile: FetchUserProfile, @@ -62,6 +64,8 @@ export class ProfileComponent implements OnInit, OnDestroy { defaultSearchFiltersInitialized = signal(false); ngOnInit(): void { + this.prerenderReady.setNotReady(); + const userId = this.route.snapshot.params['id']; const currentUser = this.loggedInUser(); @@ -97,7 +101,7 @@ export class ProfileComponent implements OnInit, OnDestroy { this.defaultSearchFiltersInitialized.set(true); } - this.prerenderReady.setReady(); + this.setMetaTags(); } private setSearchFilter(): void { @@ -108,6 +112,10 @@ export class ProfileComponent implements OnInit, OnDestroy { this.defaultSearchFiltersInitialized.set(true); } - this.prerenderReady.setReady(); + this.setMetaTags(); + } + + private setMetaTags(): void { + this.metaTags.updateMetaTags({ osfType: CurrentResourceType.Users }, this.destroyRef, { mergeDefaults: false }); } } diff --git a/src/app/shared/services/meta-tags.service.spec.ts b/src/app/shared/services/meta-tags.service.spec.ts index 0f882936c..cdcc2f28f 100644 --- a/src/app/shared/services/meta-tags.service.spec.ts +++ b/src/app/shared/services/meta-tags.service.spec.ts @@ -15,6 +15,20 @@ import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-r import { MetaTagsService } from './meta-tags.service'; import { MetadataRecordsService } from './metadata-records.service'; +function createDestroyRefMock() { + let destroyCallback: (() => void) | undefined; + const destroyRef = { + onDestroy: vi.fn((cb: () => void) => { + destroyCallback = cb; + }), + } as unknown as DestroyRef; + + return { + destroyRef, + destroy: () => destroyCallback?.(), + }; +} + describe('MetaTagsService', () => { let service: MetaTagsService; let metadataRecordsMock: { getMetadataRecord: Mock }; @@ -38,9 +52,7 @@ describe('MetaTagsService', () => { }); it('adds canonical link from url', () => { - const destroyRef = { - onDestroy: vi.fn(), - } as unknown as DestroyRef; + const { destroyRef } = createDestroyRefMock(); service.updateMetaTags( { @@ -55,9 +67,7 @@ describe('MetaTagsService', () => { }); it('uses canonicalUrl when it differs from url', () => { - const destroyRef = { - onDestroy: vi.fn(), - } as unknown as DestroyRef; + const { destroyRef } = createDestroyRefMock(); service.updateMetaTags( { @@ -73,9 +83,7 @@ describe('MetaTagsService', () => { }); it('replaces canonical link when updated again', () => { - const destroyRef = { - onDestroy: vi.fn(), - } as unknown as DestroyRef; + const { destroyRef } = createDestroyRefMock(); service.updateMetaTags( { @@ -99,12 +107,7 @@ describe('MetaTagsService', () => { }); it('removes canonical link on destroy callback', () => { - let destroyCallback: (() => void) | undefined; - const destroyRef = { - onDestroy: vi.fn((cb: () => void) => { - destroyCallback = cb; - }), - } as unknown as DestroyRef; + const { destroyRef, destroy } = createDestroyRefMock(); service.updateMetaTags( { @@ -114,9 +117,19 @@ describe('MetaTagsService', () => { destroyRef ); - destroyCallback?.(); + destroy(); const canonical = document.head.querySelector('link[rel="canonical"]'); expect(canonical).toBeNull(); }); + + it('applies osf:type without default tags when mergeDefaults is false', () => { + const { destroyRef } = createDestroyRefMock(); + + service.updateMetaTags({ osfType: 'users' }, destroyRef, { mergeDefaults: false }); + + expect(document.head.querySelector('meta[name="osf:type"]')?.getAttribute('content')).toBe('users'); + expect(document.head.querySelector('meta[name="citation_description"]')).toBeNull(); + expect(document.head.querySelector('meta[property="og:image"]')).toBeNull(); + }); }); diff --git a/src/app/shared/services/meta-tags.service.ts b/src/app/shared/services/meta-tags.service.ts index cb048cf28..b2c33a888 100644 --- a/src/app/shared/services/meta-tags.service.ts +++ b/src/app/shared/services/meta-tags.service.ts @@ -56,7 +56,11 @@ export class MetaTagsService { }; private readonly metaTagClass = 'osf-dynamic-meta'; - private metaTagStack: { metaTagsData: MetaTagsData; componentDestroyRef: DestroyRef }[] = []; + private metaTagStack: { + metaTagsData: MetaTagsData; + componentDestroyRef: DestroyRef; + mergeDefaults: boolean; + }[] = []; areMetaTagsApplied = signal(false); @@ -68,8 +72,16 @@ export class MetaTagsService { }); } - updateMetaTags(metaTagsData: MetaTagsData, componentDestroyRef: DestroyRef): void { - this.metaTagStack = [...this.metaTagStackWithout(componentDestroyRef), { metaTagsData, componentDestroyRef }]; + updateMetaTags( + metaTagsData: MetaTagsData, + componentDestroyRef: DestroyRef, + options?: { mergeDefaults?: boolean } + ): void { + const mergeDefaults = options?.mergeDefaults ?? true; + this.metaTagStack = [ + ...this.metaTagStackWithout(componentDestroyRef), + { metaTagsData, componentDestroyRef, mergeDefaults }, + ]; componentDestroyRef.onDestroy(() => { this.metaTagStack = this.metaTagStackWithout(componentDestroyRef); this.applyNearestMetaTags(); @@ -97,18 +109,18 @@ export class MetaTagsService { const nearest = this.metaTagStack.at(-1); if (nearest) { - this.applyMetaTagsData(nearest.metaTagsData); + this.applyMetaTagsData(nearest.metaTagsData, nearest.mergeDefaults); } else { this.clearMetaTags(); } } - private applyMetaTagsData(metaTagsData: MetaTagsData): void { + private applyMetaTagsData(metaTagsData: MetaTagsData, mergeDefaults = true): void { this.areMetaTagsApplied.set(false); this.prerenderReady.setNotReady(); this.removeDynamicMetaTags(); - const combinedData = { ...this.defaultMetaTags, ...metaTagsData }; + const combinedData = mergeDefaults ? { ...this.defaultMetaTags, ...metaTagsData } : metaTagsData; const headTags = this.getHeadTags(combinedData); of(metaTagsData.osfGuid) From 74412e12877da53d7d6e52102784d2aaa70d233a Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 3 Sep 2026 10:14:31 +0300 Subject: [PATCH 6/6] feat(ssr): updated docs for ssr profile --- docs/ssr-metrics.md | 24 ++++++++++++------------ docs/ssr.md | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/ssr-metrics.md b/docs/ssr-metrics.md index b7f77c8b4..798b0d7b4 100644 --- a/docs/ssr-metrics.md +++ b/docs/ssr-metrics.md @@ -25,15 +25,15 @@ In production, the SSR Node process receives **bot traffic only**. Metrics are e ## What we track -| Goal | Field | Notes | -| ------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| Render success rate | `status` | Derive success as HTTP 2xx. Failures use `0` (no Angular response) or `500` (render exception). | -| Render speed | `ttfb` | Milliseconds from request start until Angular returns a response. Server-side render time, not browser network TTFB. | -| Page completeness | `is_complete` | Checked from rendered HTML. See [is_complete rules](#is_complete-rules). | -| Content type | `content_type` | API resource type from `osf:type` meta (`nodes`, `registrations`, `preprints`, `files`). Null when not set or not inspected. | -| Bot vs other | `is_bot` | From User-Agent regex in middleware. | -| Crawler identity | `user_agent` | Truncated to 512 characters. | -| Page | `url` | Full public URL (`webUrl` + path). **Query string is stripped** (no `view_only` or other params). | +| Goal | Field | Notes | +| ------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Render success rate | `status` | Derive success as HTTP 2xx. Failures use `0` (no Angular response) or `500` (render exception). | +| Render speed | `ttfb` | Milliseconds from request start until Angular returns a response. Server-side render time, not browser network TTFB. | +| Page completeness | `is_complete` | Checked from rendered HTML. See [is_complete rules](#is_complete-rules). | +| Content type | `content_type` | API resource type from `osf:type` meta (`nodes`, `registrations`, `preprints`, `files`, `users`). Null when not set or not inspected. | +| Bot vs other | `is_bot` | From User-Agent regex in middleware. | +| Crawler identity | `user_agent` | Truncated to 512 characters. | +| Page | `url` | Full public URL (`webUrl` + path). **Query string is stripped** (no `view_only` or other params). | We do **not** track Search Console index status. β€œPages by content type” in SSR means **successful bot responses grouped by `content_type`**, not confirmed Google indexing. @@ -94,11 +94,11 @@ Read from rendered HTML: ``` -Set by `MetaTagsService` when features build meta tags via `MetaTagsBuilderService`. +Set by `MetaTagsService` (`osfType`). Project, registration, preprint, and file pages go through `MetaTagsBuilderService`. Profile pages set `osfType: users` only. -**Populated for:** projects, registrations, preprints, files. +**Populated for:** projects, registrations, preprints, files, users (`/user/:id` and `/profile`). -**Often null for:** users, institutions, meetings, collections, search, discover, and any page without `osf:type`. That is expected if those types are out of scope for the metric. +**Often null for:** institutions, meetings, collections, search, discover, and any page without `osf:type`. That is expected if those types are out of scope for the metric. --- diff --git a/docs/ssr.md b/docs/ssr.md index 93deb4d09..47b8ba14e 100644 --- a/docs/ssr.md +++ b/docs/ssr.md @@ -125,9 +125,9 @@ Translations on SSR are loaded from `browser/assets/i18n/en.json` (cached at mod ### Meta tags -`MetaTagsService` writes dynamic tags with class `osf-dynamic-meta`. Resource pages also emit `osf:type` (API type: `nodes`, `registrations`, `preprints`, `files`). Metrics read `osf:type` from the rendered HTML for `content_type`. +`MetaTagsService` writes dynamic tags with class `osf-dynamic-meta`. Pages also emit `osf:type` (API type: `nodes`, `registrations`, `preprints`, `files`, `users`). Metrics read `osf:type` from the rendered HTML for `content_type`. -Built in `MetaTagsBuilderService` for project, registration, preprint, and file pages. +Built in `MetaTagsBuilderService` for project, registration, preprint, and file pages. Profile pages call `updateMetaTags` with only `osfType: users` and `mergeDefaults: false`, so they emit a single `osf:type` tag. ---