From 5690399c34b02a3a1f0c7f225249ac2e351a7bc4 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 1 Oct 2026 20:24:23 +0300 Subject: [PATCH 1/3] [ENG-12064] Phase 2: SSR performance & reliability metrics (#1049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://openscience.atlassian.net/browse/ENG-10693 The Angular SSR (Server-Side Rendering) app will only be used for bots such as the Google Bot and similar. We will use Nginx to redirect pages from the client-side app to the SSR pages if they match certain criteria. Normal web traffic will not be server-side rendered. Phase 2 involves adding metrics to the app so that Product can assess the performance and reliability of the service without Google telling us. The following metrics (If there are some that we can’t measure, then those can be negotiated) - these may be part of a second phase after getting the basic functionality running on Production: SSR Render Success Rate - Measures how often search engine bots successfully receive a fully rendered page instead of an error or a broken response. SSR Response Speed (Time to First Byte) - Measures how quickly search engines start receiving page content after requesting it. Faster responses help search engines crawl pages more efficiently and avoid treating them as slow or unreliable (Looking at Google here....) Rendered Page Completeness - Measures whether the rendered pages include the key information search engines need (such as titles, summaries, and main content), rather than just an empty or partial page. Indexed Pages by Content Type - Measures how many projects, registrations, preprints, etc. are successfully indexed by search engines over time. This helps us understand whether this work is improving discoverability in practice. User agent, if available --- README.md | 8 +- docs/arch.md | 6 + docs/ssr-metrics.md | 146 +++++++++++++++ docs/ssr.md | 169 ++++++++++++++++++ package-lock.json | 46 ++--- src/app/app.config.server.ts | 53 ++---- src/app/app.routes.server.ts | 62 ++++++- .../redirect-if-logged-in.guard.spec.ts | 14 +- .../guards/redirect-if-logged-in.guard.ts | 7 +- .../profile/profile.component.spec.ts | 147 +++++++++++---- src/app/features/profile/profile.component.ts | 20 ++- .../models/meta-tags/meta-tags-data.model.ts | 1 + .../services/meta-tags-builder.service.ts | 5 + .../shared/services/meta-tags.service.spec.ts | 45 +++-- src/app/shared/services/meta-tags.service.ts | 25 ++- src/server.ts | 85 ++------- 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 + 22 files changed, 903 insertions(+), 207 deletions(-) create mode 100644 docs/ssr-metrics.md create mode 100644 docs/ssr.md 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/README.md b/README.md index 7d086f241..320352ec8 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). - [Sentry error filtering](docs/sentry.md). @@ -60,6 +64,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..798b0d7b4 --- /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`, `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. + +--- + +## 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` (`osfType`). Project, registration, preprint, and file pages go through `MetaTagsBuilderService`. Profile pages set `osfType: users` only. + +**Populated for:** projects, registrations, preprints, files, users (`/user/:id` and `/profile`). + +**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. + +--- + +## 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..47b8ba14e --- /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`. 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. Profile pages call `updateMetaTags` with only `osfType: users` and `mergeDefaults: false`, so they emit a single `osf:type` tag. + +--- + +## 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 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": [ { diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts index fe0a21ea7..f449500fb 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,33 @@ import { provideServerRendering, withRoutes } from '@angular/ssr'; import { SSR_CONFIG } from '@core/constants/ssr-config.token'; import { ConfigModel } from '@core/models/config.model'; +import en from '../assets/i18n/en.json'; +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'; -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`); - - if (!existsSync(translationPath)) { - return of({}); - } - - try { - return of(JSON.parse(readFileSync(translationPath, 'utf-8'))); - } catch { - return of({}); - } - } -} - -function loadSsrConfig(): ConfigModel { - const serverDistFolder = dirname(fileURLToPath(import.meta.url)); - const configPath = resolve(serverDistFolder, '../browser/assets/config/config.json'); +const serverDistFolder = dirname(fileURLToPath(import.meta.url)); +const configPath = resolve(serverDistFolder, '../browser/assets/config/config.json'); +const ssrConfig = { + ...readJsonFile(configPath, {} as ConfigModel), + throttleToken: process.env['THROTTLE_TOKEN'] || '', +} as ConfigModel; - let config = {} as ConfigModel; - - if (existsSync(configPath)) { - try { - config = JSON.parse(readFileSync(configPath, 'utf-8')); - } catch { - config = {} as ConfigModel; - } +class SsrTranslateLoader implements TranslateLoader { + getTranslation(lang: string): Observable { + return of(lang === 'en' ? en : {}); } - - return { - ...config, - throttleToken: process.env['THROTTLE_TOKEN'] || '', - } as ConfigModel; } const serverConfig: ApplicationConfig = { providers: [ provideServerRendering(withRoutes(serverRoutes)), - provideTranslateLoader(SsrFsTranslateLoader), - { provide: SSR_CONFIG, useFactory: loadSsrConfig }, + provideTranslateLoader(SsrTranslateLoader), + { provide: SSR_CONFIG, useValue: ssrConfig }, ], }; diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 89fea762c..6e31c3e26 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -1,6 +1,10 @@ import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Prerender, + }, { path: 'terms-of-use', renderMode: RenderMode.Prerender, @@ -62,23 +66,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 +121,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 +161,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 +197,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/app/core/guards/redirect-if-logged-in.guard.spec.ts b/src/app/core/guards/redirect-if-logged-in.guard.spec.ts index 880435a7c..db6b59bd0 100644 --- a/src/app/core/guards/redirect-if-logged-in.guard.spec.ts +++ b/src/app/core/guards/redirect-if-logged-in.guard.spec.ts @@ -4,6 +4,7 @@ import { MockProvider } from 'ng-mocks'; import { firstValueFrom, Observable } from 'rxjs'; +import { PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; @@ -19,7 +20,7 @@ describe('redirectIfLoggedInGuard', () => { let store: Store; let router: RouterMockType; - function setup(isAuthenticated: boolean) { + function setup(isAuthenticated: boolean, platformId = 'browser') { router = RouterMockBuilder.create().withUrl('/login').withNavigate(vi.fn().mockResolvedValue(true)).build(); TestBed.configureTestingModule({ @@ -29,6 +30,7 @@ describe('redirectIfLoggedInGuard', () => { selectors: [{ selector: UserSelectors.isAuthenticated, value: isAuthenticated }], }), MockProvider(Router, router), + MockProvider(PLATFORM_ID, platformId), ], }); @@ -71,4 +73,14 @@ describe('redirectIfLoggedInGuard', () => { expect(store.dispatch).toHaveBeenCalledTimes(1); expect(store.dispatch).toHaveBeenCalledWith(GetCurrentUser); }); + + it('should return true without dispatching or redirecting on server', async () => { + setup(true, 'server'); + + const result = await resolveGuard(); + + expect(result).toBe(true); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/core/guards/redirect-if-logged-in.guard.ts b/src/app/core/guards/redirect-if-logged-in.guard.ts index 9e6232945..92ca29b20 100644 --- a/src/app/core/guards/redirect-if-logged-in.guard.ts +++ b/src/app/core/guards/redirect-if-logged-in.guard.ts @@ -2,12 +2,17 @@ import { Store } from '@ngxs/store'; import { map, switchMap, take } from 'rxjs'; -import { inject } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { inject, PLATFORM_ID } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { GetCurrentUser, UserSelectors } from '@osf/core/store/user'; export const redirectIfLoggedInGuard: CanActivateFn = () => { + if (!isPlatformBrowser(inject(PLATFORM_ID))) { + return true; + } + const store = inject(Store); const router = inject(Router); 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/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.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 83b2ca1f8..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) @@ -158,6 +170,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..2851eb5cb 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,82 +10,30 @@ 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'], + trustProxyHeaders: [ + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-forwarded-prefix', + 'x-forwarded-port', + ], }); - -const isBot = (ua: string) => /bot|googlebot|crawler|spider|robot|crawling/i.test(ua); +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; - - 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; - } - - const body = { - url: req.originalUrl, - 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 20606908da46a8da2434b12d3512b12ea9d861bd Mon Sep 17 00:00:00 2001 From: "Brian J. Geiger" Date: Thu, 1 Oct 2026 13:53:56 -0400 Subject: [PATCH 2/3] chore(documentation): Update version and CHANGELOG --- CHANGELOG | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 4605479be..371cc7ba6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,10 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO. +26.20.0 (2026-10-01) +==================== +* SSR Metrics + 26.19.0 (2026-09-23) ==================== * Remove leave this view from top of registration VOLs diff --git a/package.json b/package.json index 593740d43..8cc08cdbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "osf", - "version": "26.19.0", + "version": "26.20.0", "scripts": { "ng": "ng", "analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks", From 63b6e33111030a8f5be12c916ba49c21e5b09c4e Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Tue, 6 Oct 2026 13:51:28 -0400 Subject: [PATCH 3/3] chore(conflicts): Fix merge conflicts --- package-lock.json | 44 -------------------------------------------- 1 file changed, 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7dd5fe14..23bdf0250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6214,15 +6214,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { -<<<<<<< HEAD "version": "2.11.27", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", -======= - "version": "2.11.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", - "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", ->>>>>>> develop "dev": true, "license": "Apache-2.0", "bin": { @@ -6341,15 +6335,9 @@ } }, "node_modules/browserslist": { -<<<<<<< HEAD "version": "4.29.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", -======= - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", ->>>>>>> develop "dev": true, "funding": [ { @@ -6367,19 +6355,11 @@ ], "license": "MIT", "dependencies": { -<<<<<<< HEAD "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.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" ->>>>>>> develop }, "bin": { "browserslist": "cli.js" @@ -6537,15 +6517,9 @@ } }, "node_modules/caniuse-lite": { -<<<<<<< HEAD "version": "1.0.30001814", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", -======= - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", ->>>>>>> develop "dev": true, "funding": [ { @@ -7247,15 +7221,9 @@ } }, "node_modules/electron-to-chromium": { -<<<<<<< HEAD "version": "1.5.444", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", -======= - "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==", ->>>>>>> develop "dev": true, "license": "ISC" }, @@ -10046,15 +10014,9 @@ } }, "node_modules/node-releases": { -<<<<<<< HEAD "version": "2.0.57", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", -======= - "version": "2.0.54", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", - "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", ->>>>>>> develop "dev": true, "license": "MIT", "engines": { @@ -12390,15 +12352,9 @@ } }, "node_modules/update-browserslist-db": { -<<<<<<< HEAD "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", -======= - "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==", ->>>>>>> develop "dev": true, "funding": [ {