Adam 7301e70ddd fix(platform-server): correctly handle absolute relative URLs (#37341)
Previously, we would simply prepend any relative URL with the HREF
for the current route (pulled from document.location). However,
this does not correctly account for the leading slash URLs that
would otherwise be parsed correctly in the browser, or the
presence of a base HREF in the DOM.

Therefore, we use the built-in URL implementation for NodeJS,
which implements the WHATWG standard that's used in the browser.
We also pull the base HREF from the DOM, falling back on the full
HREF as the browser would, to form the correct request URL.

Fixes #37314

PR Close #37341
2020-06-09 08:27:00 -07:00

116 lines
3.4 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {DOCUMENT, LocationChangeEvent, LocationChangeListener, PlatformLocation, ɵgetDOM as getDOM} from '@angular/common';
import {Inject, Injectable, Optional} from '@angular/core';
import {Subject} from 'rxjs';
import * as url from 'url';
import {INITIAL_CONFIG, PlatformConfig} from './tokens';
function parseUrl(urlStr: string) {
const parsedUrl = url.parse(urlStr);
return {
hostname: parsedUrl.hostname || '',
protocol: parsedUrl.protocol || '',
port: parsedUrl.port || '',
pathname: parsedUrl.pathname || '',
search: parsedUrl.search || '',
hash: parsedUrl.hash || '',
};
}
/**
* Server-side implementation of URL state. Implements `pathname`, `search`, and `hash`
* but not the state stack.
*/
@Injectable()
export class ServerPlatformLocation implements PlatformLocation {
public readonly href: string = '/';
public readonly hostname: string = '/';
public readonly protocol: string = '/';
public readonly port: string = '/';
public readonly pathname: string = '/';
public readonly search: string = '';
public readonly hash: string = '';
private _hashUpdate = new Subject<LocationChangeEvent>();
constructor(
@Inject(DOCUMENT) private _doc: any, @Optional() @Inject(INITIAL_CONFIG) _config: any) {
const config = _config as PlatformConfig | null;
if (!!config && !!config.url) {
const parsedUrl = parseUrl(config.url);
this.hostname = parsedUrl.hostname;
this.protocol = parsedUrl.protocol;
this.port = parsedUrl.port;
this.pathname = parsedUrl.pathname;
this.search = parsedUrl.search;
this.hash = parsedUrl.hash;
this.href = _doc.location.href;
}
}
getBaseHrefFromDOM(): string {
return getDOM().getBaseHref(this._doc)!;
}
onPopState(fn: LocationChangeListener): void {
// No-op: a state stack is not implemented, so
// no events will ever come.
}
onHashChange(fn: LocationChangeListener): void {
this._hashUpdate.subscribe(fn);
}
get url(): string {
return `${this.pathname}${this.search}${this.hash}`;
}
private setHash(value: string, oldUrl: string) {
if (this.hash === value) {
// Don't fire events if the hash has not changed.
return;
}
(this as {hash: string}).hash = value;
const newUrl = this.url;
scheduleMicroTask(
() => this._hashUpdate.next(
{type: 'hashchange', state: null, oldUrl, newUrl} as LocationChangeEvent));
}
replaceState(state: any, title: string, newUrl: string): void {
const oldUrl = this.url;
const parsedUrl = parseUrl(newUrl);
(this as {pathname: string}).pathname = parsedUrl.pathname;
(this as {search: string}).search = parsedUrl.search;
this.setHash(parsedUrl.hash, oldUrl);
}
pushState(state: any, title: string, newUrl: string): void {
this.replaceState(state, title, newUrl);
}
forward(): void {
throw new Error('Not implemented');
}
back(): void {
throw new Error('Not implemented');
}
// History API isn't available on server, therefore return undefined
getState(): unknown {
return undefined;
}
}
export function scheduleMicroTask(fn: Function) {
Zone.current.scheduleMicroTask('scheduleMicrotask', fn);
}