diff --git a/projects/core/router/url/index.test.ts b/projects/core/router/url/index.test.ts index 9ded952d..62ff3801 100644 --- a/projects/core/router/url/index.test.ts +++ b/projects/core/router/url/index.test.ts @@ -1,7 +1,8 @@ -import { Component } from '@angular/core'; +import { Component, REQUEST } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; import { Subject } from 'rxjs'; +import { IS_BROWSER, IS_SERVER } from '@signality/core/internal'; import { url } from './index'; describe(url.name, () => { @@ -118,4 +119,83 @@ describe(url.name, () => { expect(component.absoluteUrl()).toBe('http://localhost:4200/dashboard'); }); }); + + describe('server rendering', () => { + @Component({ template: '{{ relativeUrl() }}|{{ absoluteUrl() }}' }) + class TestComponent { + readonly relativeUrl = url(); + readonly absoluteUrl = url({ absolute: true }); + } + + const configureServer = (request: Request | null) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: mockRouter }, + { provide: IS_SERVER, useValue: true }, + { provide: IS_BROWSER, useValue: false }, + { provide: REQUEST, useValue: request }, + ], + }); + }; + + const createComponent = () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + return fixture.componentInstance; + }; + + it('should prepend the request origin to an absolute URL', () => { + configureServer(new Request('https://example.com/home')); + + const component = createComponent(); + + expect(component.relativeUrl()).toBe('/home'); + expect(component.absoluteUrl()).toBe('https://example.com/home'); + }); + + it('should take only the origin from the request and the path from the router', () => { + mockRouter.url = '/products/123?sort=name'; + configureServer(new Request('https://example.com:8443/redirected')); + + const component = createComponent(); + + expect(component.absoluteUrl()).toBe('https://example.com:8443/products/123?sort=name'); + }); + + it('should fall back to a relative URL and warn when there is no request', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + configureServer(null); + + const component = createComponent(); + + expect(component.absoluteUrl()).toBe('/home'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[url]')); + }); + + it('should not warn when `absolute` is not requested', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: mockRouter }, + { provide: IS_SERVER, useValue: true }, + { provide: IS_BROWSER, useValue: false }, + { provide: REQUEST, useValue: null }, + ], + }); + + @Component({ template: '{{ relativeUrl() }}' }) + class RelativeOnly { + readonly relativeUrl = url(); + } + + const fixture = TestBed.createComponent(RelativeOnly); + fixture.detectChanges(); + + expect(fixture.componentInstance.relativeUrl()).toBe('/home'); + expect(warn).not.toHaveBeenCalled(); + }); + }); }); diff --git a/projects/core/router/url/index.ts b/projects/core/router/url/index.ts index 433dedce..42f62e00 100644 --- a/projects/core/router/url/index.ts +++ b/projects/core/router/url/index.ts @@ -1,4 +1,4 @@ -import { type CreateSignalOptions, inject, signal, type Signal } from '@angular/core'; +import { type CreateSignalOptions, inject, REQUEST, signal, type Signal } from '@angular/core'; import { Router } from '@angular/router'; import { constSignal, setupContext } from '@signality/core/internal'; import type { WithInjector } from '@signality/core/types'; @@ -7,6 +7,10 @@ import { routerListener } from '@signality/core/router/router-listener'; export interface UrlOptions extends CreateSignalOptions, WithInjector { /** * Include origin (protocol + host) for absolute URL. + * + * On the server the origin comes from the `REQUEST` token. While prerendering there is no + * request, so the origin is unknown and the signal falls back to a relative URL. + * * @default false */ readonly absolute?: boolean; @@ -41,7 +45,24 @@ export function url(options?: UrlOptions): Signal { const router = inject(Router); if (isServer) { - return constSignal(router.url); + const relativeUrl = router.url; + + if (!options?.absolute) { + return constSignal(relativeUrl); + } + + const request = inject(REQUEST, { optional: true }); + if (!request) { + if (ngDevMode) { + console.warn( + '[url] `absolute` is ignored while prerendering, because the deployment origin is unknown at build time. Falling back to a relative URL.' + ); + } + + return constSignal(relativeUrl); + } + + return constSignal(new URL(request.url).origin + relativeUrl); } const getUrl = () => { diff --git a/projects/docs/router/url.md b/projects/docs/router/url.md index 79205d6d..4091b2e0 100644 --- a/projects/docs/router/url.md +++ b/projects/docs/router/url.md @@ -91,6 +91,10 @@ export class ShareButtons { On the server, the signal initializes with the current URL from the router's [`url`](https://angular.dev/api/router/Router#url) property. +With `absolute: true`, the origin is taken from the [`REQUEST`](https://angular.dev/api/core/REQUEST) token, so the rendered value matches the origin the visitor actually requested — including behind a reverse proxy that terminates TLS (`X-Forwarded-Host` and `X-Forwarded-Proto` are honoured). + +During **prerendering** there is no request, and therefore no origin: the deployment host is not knowable at build time. The signal falls back to a relative URL and logs a warning in development builds. Keep this in mind when using `absolute: true` for crawler-visible metadata such as `og:url` — a prerendered page will carry a relative value until it is hydrated. + ## Type Definitions ```typescript