angular/packages/platform-server/src/utils.ts
Thomas Nguyen 89e860cc30 refactor(core): Add four tests and fix code to make tests pass. (#55747)
The first test asserts that bubbling does not work right now.

The second asserts that stopPropagation works, which should pass when test #1 passes too.

The third test asserts properties about the events passed to the event handler.

THe fourth test asserts that mouse events do not translate to jsaction nor help emit the jsaction binary. This required a change in code to make this pass.

PR Close #55747
2024-05-14 09:38:43 -07:00

293 lines
10 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 {
APP_ID,
ApplicationRef,
CSP_NONCE,
InjectionToken,
PlatformRef,
Provider,
Renderer2,
StaticProvider,
Type,
ɵannotateForHydration as annotateForHydration,
ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED,
ɵSSR_CONTENT_INTEGRITY_MARKER as SSR_CONTENT_INTEGRITY_MARKER,
ɵwhenStable as whenStable,
} from '@angular/core';
import {PlatformState} from './platform_state';
import {platformServer} from './server';
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG} from './tokens';
import {createScript} from './transfer_state';
/**
* Event dispatch (JSAction) script is inlined into the HTML by the build
* process to avoid extra blocking request on a page. The script looks like this:
* ```
* <script type="text/javascript" id="ng-event-dispatch-contract">...</script>
* ```
* This const represents the "id" attribute value.
*/
export const EVENT_DISPATCH_SCRIPT_ID = 'ng-event-dispatch-contract';
interface PlatformOptions {
document?: string | Document;
url?: string;
platformProviders?: Provider[];
}
/**
* Creates an instance of a server platform (with or without JIT compiler support
* depending on the `ngJitMode` global const value), using provided options.
*/
function createServerPlatform(options: PlatformOptions): PlatformRef {
const extraProviders = options.platformProviders ?? [];
return platformServer([
{provide: INITIAL_CONFIG, useValue: {document: options.document, url: options.url}},
extraProviders,
]);
}
/**
* Finds and returns inlined event dispatch script if it exists.
* See the `EVENT_DISPATCH_SCRIPT_ID` const docs for additional info.
*/
function findEventDispatchScript(doc: Document) {
return doc.getElementById(EVENT_DISPATCH_SCRIPT_ID);
}
/**
* Removes inlined event dispatch script if it exists.
* See the `EVENT_DISPATCH_SCRIPT_ID` const docs for additional info.
*/
function removeEventDispatchScript(doc: Document) {
findEventDispatchScript(doc)?.remove();
}
/**
* Annotate nodes for hydration and remove event dispatch script when not needed.
*/
function prepareForHydration(platformState: PlatformState, applicationRef: ApplicationRef): void {
const environmentInjector = applicationRef.injector;
const doc = platformState.getDocument();
if (!environmentInjector.get(IS_HYDRATION_DOM_REUSE_ENABLED, false)) {
// Hydration is diabled, remove inlined event dispatch script.
// (which was injected by the build process) from the HTML.
removeEventDispatchScript(doc);
return;
}
appendSsrContentIntegrityMarker(doc);
const eventTypesToBeReplayed = annotateForHydration(applicationRef, doc);
if (eventTypesToBeReplayed) {
insertEventRecordScript(
environmentInjector.get(APP_ID),
doc,
eventTypesToBeReplayed,
environmentInjector.get(CSP_NONCE, null),
);
} else {
// No events to replay, we should remove inlined event dispatch script
// (which was injected by the build process) from the HTML.
removeEventDispatchScript(doc);
}
}
/**
* Creates a marker comment node and append it into the `<body>`.
* Some CDNs have mechanisms to remove all comment node from HTML.
* This behaviour breaks hydration, so we'll detect on the client side if this
* marker comment is still available or else throw an error
*/
function appendSsrContentIntegrityMarker(doc: Document) {
// Adding a ng hydration marker comment
const comment = doc.createComment(SSR_CONTENT_INTEGRITY_MARKER);
doc.body.firstChild
? doc.body.insertBefore(comment, doc.body.firstChild)
: doc.body.append(comment);
}
/**
* Adds the `ng-server-context` attribute to host elements of all bootstrapped components
* within a given application.
*/
function appendServerContextInfo(applicationRef: ApplicationRef) {
const injector = applicationRef.injector;
let serverContext = sanitizeServerContext(injector.get(SERVER_CONTEXT, DEFAULT_SERVER_CONTEXT));
applicationRef.components.forEach((componentRef) => {
const renderer = componentRef.injector.get(Renderer2);
const element = componentRef.location.nativeElement;
if (element) {
renderer.setAttribute(element, 'ng-server-context', serverContext);
}
});
}
function insertEventRecordScript(
appId: string,
doc: Document,
eventTypesToBeReplayed: Set<string>,
nonce: string | null,
): void {
const eventDispatchScript = findEventDispatchScript(doc);
if (eventDispatchScript) {
const events = Array.from(eventTypesToBeReplayed);
const captureEventTypes = [];
const eventTypes = [];
for (const eventType of events) {
if (
eventType === 'focus' ||
eventType === 'blur' ||
eventType === 'error' ||
eventType === 'load' ||
eventType === 'toggle'
) {
captureEventTypes.push(eventType);
} else {
eventTypes.push(eventType);
}
}
// This is defined in packages/core/primitives/event-dispatch/contract_binary.ts
const replayScriptContents = `window.__jsaction_bootstrap('ngContracts', document.body, ${JSON.stringify(
appId,
)}, ${JSON.stringify(eventTypes)}${captureEventTypes.length ? ',' + JSON.stringify(captureEventTypes) : ''});`;
const replayScript = createScript(doc, replayScriptContents, nonce);
// Insert replay script right after inlined event dispatch script, since it
// relies on `__jsaction_bootstrap` to be defined in the global scope.
eventDispatchScript.after(replayScript);
}
}
async function _render(platformRef: PlatformRef, applicationRef: ApplicationRef): Promise<string> {
// Block until application is stable.
await whenStable(applicationRef);
const platformState = platformRef.injector.get(PlatformState);
prepareForHydration(platformState, applicationRef);
// Run any BEFORE_APP_SERIALIZED callbacks just before rendering to string.
const environmentInjector = applicationRef.injector;
const callbacks = environmentInjector.get(BEFORE_APP_SERIALIZED, null);
if (callbacks) {
const asyncCallbacks: Promise<void>[] = [];
for (const callback of callbacks) {
try {
const callbackResult = callback();
if (callbackResult) {
asyncCallbacks.push(callbackResult);
}
} catch (e) {
// Ignore exceptions.
console.warn('Ignoring BEFORE_APP_SERIALIZED Exception: ', e);
}
}
if (asyncCallbacks.length) {
for (const result of await Promise.allSettled(asyncCallbacks)) {
if (result.status === 'rejected') {
console.warn('Ignoring BEFORE_APP_SERIALIZED Exception: ', result.reason);
}
}
}
}
appendServerContextInfo(applicationRef);
const output = platformState.renderToString();
// Destroy the application in a macrotask, this allows pending promises to be settled and errors
// to be surfaced to the users.
await new Promise<void>((resolve) => {
setTimeout(() => {
platformRef.destroy();
resolve();
}, 0);
});
return output;
}
/**
* Specifies the value that should be used if no server context value has been provided.
*/
const DEFAULT_SERVER_CONTEXT = 'other';
/**
* An internal token that allows providing extra information about the server context
* (e.g. whether SSR or SSG was used). The value is a string and characters other
* than [a-zA-Z0-9\-] are removed. See the default value in `DEFAULT_SERVER_CONTEXT` const.
*/
export const SERVER_CONTEXT = new InjectionToken<string>('SERVER_CONTEXT');
/**
* Sanitizes provided server context:
* - removes all characters other than a-z, A-Z, 0-9 and `-`
* - returns `other` if nothing is provided or the string is empty after sanitization
*/
function sanitizeServerContext(serverContext: string): string {
const context = serverContext.replace(/[^a-zA-Z0-9\-]/g, '');
return context.length > 0 ? context : DEFAULT_SERVER_CONTEXT;
}
/**
* Bootstraps an application using provided NgModule and serializes the page content to string.
*
* @param moduleType A reference to an NgModule that should be used for bootstrap.
* @param options Additional configuration for the render operation:
* - `document` - the document of the page to render, either as an HTML string or
* as a reference to the `document` instance.
* - `url` - the URL for the current render request.
* - `extraProviders` - set of platform level providers for the current render request.
*
* @publicApi
*/
export async function renderModule<T>(
moduleType: Type<T>,
options: {document?: string | Document; url?: string; extraProviders?: StaticProvider[]},
): Promise<string> {
const {document, url, extraProviders: platformProviders} = options;
const platformRef = createServerPlatform({document, url, platformProviders});
const moduleRef = await platformRef.bootstrapModule(moduleType);
const applicationRef = moduleRef.injector.get(ApplicationRef);
return _render(platformRef, applicationRef);
}
/**
* Bootstraps an instance of an Angular application and renders it to a string.
* ```typescript
* const bootstrap = () => bootstrapApplication(RootComponent, appConfig);
* const output: string = await renderApplication(bootstrap);
* ```
*
* @param bootstrap A method that when invoked returns a promise that returns an `ApplicationRef`
* instance once resolved.
* @param options Additional configuration for the render operation:
* - `document` - the document of the page to render, either as an HTML string or
* as a reference to the `document` instance.
* - `url` - the URL for the current render request.
* - `platformProviders` - the platform level providers for the current render request.
*
* @returns A Promise, that returns serialized (to a string) rendered page, once resolved.
*
* @publicApi
*/
export async function renderApplication<T>(
bootstrap: () => Promise<ApplicationRef>,
options: {document?: string | Document; url?: string; platformProviders?: Provider[]},
): Promise<string> {
const platformRef = createServerPlatform(options);
const applicationRef = await bootstrap();
return _render(platformRef, applicationRef);
}