Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/angular/build/src/builders/dev-server/vite-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ export async function* serveWithVite(
extensions?.middleware,
transformers?.indexHtml,
thirdPartySourcemaps,
!!browserOptions.aot,
);

server = await createServer(serverConfiguration);
Expand Down Expand Up @@ -503,6 +504,7 @@ export async function setupServer(
extensionMiddleware?: Connect.NextHandleFunction[],
indexHtmlTransformer?: (content: string) => Promise<string>,
thirdPartySourcemaps = false,
aot = false,
): Promise<InlineConfig> {
const proxy = await loadProxyConfiguration(
serverOptions.workspaceRoot,
Expand Down Expand Up @@ -589,6 +591,7 @@ export async function setupServer(
// Include all implict dependencies from the external packages internal option
include: externalMetadata.implicitServer,
ssr: true,
aot,
prebundleTransformer,
zoneless,
target,
Expand Down Expand Up @@ -625,6 +628,7 @@ export async function setupServer(
zoneless,
loader: prebundleLoaderExtensions,
thirdPartySourcemaps,
aot,
}),
};

Expand Down Expand Up @@ -663,6 +667,7 @@ function getDepOptimizationConfig({
ssr,
loader,
thirdPartySourcemaps,
aot,
}: {
disabled: boolean;
exclude: string[];
Expand All @@ -673,6 +678,7 @@ function getDepOptimizationConfig({
zoneless: boolean;
loader?: EsbuildLoaderOption;
thirdPartySourcemaps: boolean;
aot: boolean;
}): DepOptimizationConfig {
const plugins: ViteEsBuildPlugin[] = [
{
Expand Down Expand Up @@ -704,6 +710,9 @@ function getDepOptimizationConfig({
supported: getFeatureSupport(target, zoneless),
plugins,
loader,
define: {
'ngJitMode': aot ? 'false' : 'true',
},
resolveExtensions: ['.mjs', '.js', '.cjs'],
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ɵwhenStable as whenStable,
ɵConsole,
} from '@angular/core';
import { BootstrapContext } from '@angular/platform-browser';
import {
INITIAL_CONFIG,
ɵINTERNAL_SERVER_PLATFORM_PROVIDERS as INTERNAL_SERVER_PLATFORM_PROVIDERS,
Expand Down Expand Up @@ -76,7 +77,7 @@ async function* getRoutesFromRouterConfig(
}

export async function* extractRoutes(
bootstrapAppFnOrModule: (() => Promise<ApplicationRef>) | Type<unknown>,
bootstrapAppFnOrModule: ((context: BootstrapContext) => Promise<ApplicationRef>) | Type<unknown>,
document: string,
): AsyncIterableIterator<RouterResult> {
const platformRef = createPlatformFactory(platformCore, 'server', [
Expand Down Expand Up @@ -106,7 +107,7 @@ export async function* extractRoutes(
try {
let applicationRef: ApplicationRef;
if (isBootstrapFn(bootstrapAppFnOrModule)) {
applicationRef = await bootstrapAppFnOrModule();
applicationRef = await bootstrapAppFnOrModule({ platformRef });
} else {
const moduleRef = await platformRef.bootstrapModule(bootstrapAppFnOrModule);
applicationRef = moduleRef.injector.get(ApplicationRef);
Expand All @@ -131,7 +132,9 @@ export async function* extractRoutes(
}
}

function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
function isBootstrapFn(
value: unknown,
): value is (context: BootstrapContext) => Promise<ApplicationRef> {
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
return typeof value === 'function' && !('ɵmod' in value);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@

import type { ApplicationRef, Type, ɵConsole } from '@angular/core';
import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
import type { BootstrapContext } from '@angular/platform-browser';

Check failure on line 11 in packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts

View workflow job for this annotation

GitHub Actions / lint

`@angular/platform-browser` type import should occur before type import of `@angular/platform-server`
import type { extractRoutes } from '../routes-extractor/extractor';

export interface MainServerBundleExports {
/** Standalone application bootstrapping function. */
default: (() => Promise<ApplicationRef>) | Type<unknown>;
default: ((context: BootstrapContext) => Promise<ApplicationRef>) | Type<unknown>;
}

export interface RenderUtilsServerBundleExports {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { ApplicationRef, StaticProvider } from '@angular/core';
import type { BootstrapContext } from '@angular/platform-browser';
import assert from 'node:assert';
import { basename } from 'node:path';
import { loadEsmModuleFromMemory } from './load-esm-from-memory';
Expand Down Expand Up @@ -139,7 +140,9 @@ export async function renderPage({
};
}

function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
function isBootstrapFn(
value: unknown,
): value is (context: BootstrapContext) => Promise<ApplicationRef> {
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
return typeof value === 'function' && !('ɵmod' in value);
}
203 changes: 203 additions & 0 deletions packages/angular/ssr/node/src/common-engine/common-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* @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.dev/license
*/

import { ApplicationRef, StaticProvider, Type } from '@angular/core';
import { BootstrapContext } from '@angular/platform-browser';
import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
import * as fs from 'node:fs';
import { dirname, join, normalize, resolve } from 'node:path';
import { URL } from 'node:url';
import { CommonEngineInlineCriticalCssProcessor } from './inline-css-processor';
import {
noopRunMethodAndMeasurePerf,
printPerformanceLogs,
runMethodAndMeasurePerf,
} from './peformance-profiler';

const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/;

export interface CommonEngineOptions {
/** A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an NgModule. */
bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise<ApplicationRef>);

/** A set of platform level providers for all requests. */
providers?: StaticProvider[];

/** Enable request performance profiling data collection and printing the results in the server console. */
enablePerformanceProfiler?: boolean;
}

export interface CommonEngineRenderOptions {
/** A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an NgModule. */
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);

/** A set of platform level providers for the current request. */
providers?: StaticProvider[];
url?: string;
document?: string;
documentFilePath?: string;

/**
* Reduce render blocking requests by inlining critical CSS.
* Defaults to true.
*/
inlineCriticalCss?: boolean;

/**
* Base path location of index file.
* Defaults to the 'documentFilePath' dirname when not provided.
*/
publicPath?: string;
}

/**
* A common engine to use to server render an application.
*/

export class CommonEngine {
private readonly templateCache = new Map<string, string>();
private readonly inlineCriticalCssProcessor = new CommonEngineInlineCriticalCssProcessor();
private readonly pageIsSSG = new Map<string, boolean>();

constructor(private options?: CommonEngineOptions) {}

/**
* Render an HTML document for a specific URL with specified
* render options
*/
async render(opts: CommonEngineRenderOptions): Promise<string> {
const enablePerformanceProfiler = this.options?.enablePerformanceProfiler;

const runMethod = enablePerformanceProfiler
? runMethodAndMeasurePerf
: noopRunMethodAndMeasurePerf;

let html = await runMethod('Retrieve SSG Page', () => this.retrieveSSGPage(opts));

if (html === undefined) {
html = await runMethod('Render Page', () => this.renderApplication(opts));

if (opts.inlineCriticalCss !== false) {
const content = await runMethod('Inline Critical CSS', () =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.inlineCriticalCss(html!, opts),

Check failure on line 88 in packages/angular/ssr/node/src/common-engine/common-engine.ts

View workflow job for this annotation

GitHub Actions / lint

This assertion is unnecessary since the receiver accepts the original type of the expression
);

html = content;
}
}

if (enablePerformanceProfiler) {
printPerformanceLogs();
}

return html;
}

private inlineCriticalCss(html: string, opts: CommonEngineRenderOptions): Promise<string> {
const outputPath =
opts.publicPath ?? (opts.documentFilePath ? dirname(opts.documentFilePath) : '');

return this.inlineCriticalCssProcessor.process(html, outputPath);
}

private async retrieveSSGPage(opts: CommonEngineRenderOptions): Promise<string | undefined> {
const { publicPath, documentFilePath, url } = opts;
if (!publicPath || !documentFilePath || url === undefined) {
return undefined;
}

const { pathname } = new URL(url, 'resolve://');
// Do not use `resolve` here as otherwise it can lead to path traversal vulnerability.
// See: https://portswigger.net/web-security/file-path-traversal
const pagePath = join(publicPath, pathname, 'index.html');

if (this.pageIsSSG.get(pagePath)) {
// Serve pre-rendered page.
return fs.promises.readFile(pagePath, 'utf-8');
}

if (!pagePath.startsWith(normalize(publicPath))) {
// Potential path traversal detected.
return undefined;
}

if (pagePath === resolve(documentFilePath) || !(await exists(pagePath))) {
// View matches with prerender path or file does not exist.
this.pageIsSSG.set(pagePath, false);

return undefined;
}

// Static file exists.
const content = await fs.promises.readFile(pagePath, 'utf-8');
const isSSG = SSG_MARKER_REGEXP.test(content);
this.pageIsSSG.set(pagePath, isSSG);

return isSSG ? content : undefined;
}

private async renderApplication(opts: CommonEngineRenderOptions): Promise<string> {
const moduleOrFactory = this.options?.bootstrap ?? opts.bootstrap;
if (!moduleOrFactory) {
throw new Error('A module or bootstrap option must be provided.');
}

const extraProviders: StaticProvider[] = [
{ provide: ɵSERVER_CONTEXT, useValue: 'ssr' },
...(opts.providers ?? []),
...(this.options?.providers ?? []),
];

let document = opts.document;
if (!document && opts.documentFilePath) {
document = await this.getDocument(opts.documentFilePath);
}

const commonRenderingOptions = {
url: opts.url,
document,
};

return isBootstrapFn(moduleOrFactory)
? renderApplication(moduleOrFactory, {
platformProviders: extraProviders,
...commonRenderingOptions,
})
: renderModule(moduleOrFactory, { extraProviders, ...commonRenderingOptions });
}

/** Retrieve the document from the cache or the filesystem */
private async getDocument(filePath: string): Promise<string> {
let doc = this.templateCache.get(filePath);

if (!doc) {
doc = await fs.promises.readFile(filePath, 'utf-8');
this.templateCache.set(filePath, doc);
}

return doc;
}
}

async function exists(path: fs.PathLike): Promise<boolean> {
try {
await fs.promises.access(path, fs.constants.F_OK);

return true;
} catch {
return false;
}
}

function isBootstrapFn(
value: unknown,
): value is (context: BootstrapContext) => Promise<ApplicationRef> {
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
return typeof value === 'function' && !('ɵmod' in value);
}
8 changes: 6 additions & 2 deletions packages/angular/ssr/src/common-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { ApplicationRef, StaticProvider, Type } from '@angular/core';
import { BootstrapContext } from '@angular/platform-browser';
import { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
import * as fs from 'node:fs';
import { dirname, join, normalize, resolve } from 'node:path';
Expand All @@ -22,7 +23,8 @@ const SSG_MARKER_REGEXP = /ng-server-context=["']\w*\|?ssg\|?\w*["']/;

export interface CommonEngineOptions {
/** A method that when invoked returns a promise that returns an `ApplicationRef` instance once resolved or an NgModule. */
bootstrap?: Type<{}> | (() => Promise<ApplicationRef>);
bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise<ApplicationRef>);

/** A set of platform level providers for all requests. */
providers?: StaticProvider[];
/** Enable request performance profiling data collection and printing the results in the server console. */
Expand Down Expand Up @@ -200,7 +202,9 @@ async function exists(path: fs.PathLike): Promise<boolean> {
}
}

function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
function isBootstrapFn(
value: unknown,
): value is (context: BootstrapContext) => Promise<ApplicationRef> {
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
return typeof value === 'function' && !('ɵmod' in value);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { ApplicationRef, StaticProvider, Type } from '@angular/core';
import type { BootstrapContext } from '@angular/platform-browser';
import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
import assert from 'node:assert';
import { workerData } from 'node:worker_threads';
Expand Down Expand Up @@ -119,7 +120,9 @@ async function render({ serverBundlePath, document, url }: RenderRequest): Promi
return Promise.race([renderAppPromise, renderingTimeout]).finally(() => clearTimeout(timer));
}

function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
function isBootstrapFn(
value: unknown,
): value is (context: BootstrapContext) => Promise<ApplicationRef> {
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
return typeof value === 'function' && !('ɵmod' in value);
}
Expand Down
Loading
Loading