diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md index 3c9501138fb5..840e3c987b3f 100644 --- a/goldens/public-api/angular/ssr/index.api.md +++ b/goldens/public-api/angular/ssr/index.api.md @@ -13,6 +13,9 @@ export class AngularAppEngine { static ɵhooks: Hooks; } +// @public +export function createRequestHandler(handler: RequestHandlerFunction): RequestHandlerFunction; + // @public export enum PrerenderFallback { Client = 1, diff --git a/goldens/public-api/angular/ssr/node/index.api.md b/goldens/public-api/angular/ssr/node/index.api.md index a15378457b4d..84fb4f129929 100644 --- a/goldens/public-api/angular/ssr/node/index.api.md +++ b/goldens/public-api/angular/ssr/node/index.api.md @@ -43,6 +43,9 @@ export interface CommonEngineRenderOptions { url?: string; } +// @public +export function createNodeRequestHandler(handler: T): T; + // @public export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage): Request; diff --git a/packages/angular/build/src/builders/application/index.ts b/packages/angular/build/src/builders/application/index.ts index b99a19e9402f..bb161ad9ba15 100644 --- a/packages/angular/build/src/builders/application/index.ts +++ b/packages/angular/build/src/builders/application/index.ts @@ -88,7 +88,7 @@ export async function* buildApplicationInternal( yield* runEsBuildBuildAction( async (rebuildState) => { - const { serverEntryPoint, jsonLogs } = normalizedOptions; + const { serverEntryPoint, jsonLogs, disableFullServerManifestGeneration } = normalizedOptions; const startTime = process.hrtime.bigint(); const result = await executeBuild(normalizedOptions, context, rebuildState); @@ -96,7 +96,7 @@ export async function* buildApplicationInternal( if (jsonLogs) { result.addLog(await createJsonBuildManifest(result, normalizedOptions)); } else { - if (serverEntryPoint) { + if (serverEntryPoint && !disableFullServerManifestGeneration) { const prerenderedRoutesLength = Object.keys(result.prerenderedRoutes).length; let prerenderMsg = `Prerendered ${prerenderedRoutesLength} static route`; prerenderMsg += prerenderedRoutesLength !== 1 ? 's.' : '.'; diff --git a/packages/angular/build/src/builders/dev-server/vite-server.ts b/packages/angular/build/src/builders/dev-server/vite-server.ts index 36667323251b..6c279b50c750 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -14,9 +14,14 @@ import { readFile } from 'node:fs/promises'; import { builtinModules, isBuiltin } from 'node:module'; import { join } from 'node:path'; import type { Connect, DepOptimizationConfig, InlineConfig, ViteDevServer } from 'vite'; -import { createAngularMemoryPlugin } from '../../tools/vite/angular-memory-plugin'; -import { createAngularLocaleDataPlugin } from '../../tools/vite/i18n-locale-plugin'; -import { createRemoveIdPrefixPlugin } from '../../tools/vite/id-prefix-plugin'; +import { + ServerSsrMode, + createAngularLocaleDataPlugin, + createAngularMemoryPlugin, + createAngularSetupMiddlewaresPlugin, + createAngularSsrTransformPlugin, + createRemoveIdPrefixPlugin, +} from '../../tools/vite/plugins'; import { loadProxyConfiguration, normalizeSourceMaps } from '../../utils'; import { loadEsmModule } from '../../utils/load-esm'; import { Result, ResultFile, ResultKind } from '../application/results'; @@ -313,6 +318,17 @@ export async function* serveWithVite( ? browserOptions.polyfills : [browserOptions.polyfills]; + let ssrMode: ServerSsrMode = ServerSsrMode.NoSsr; + if ( + browserOptions.outputMode && + typeof browserOptions.ssr === 'object' && + browserOptions.ssr.entry + ) { + ssrMode = ServerSsrMode.ExternalSsrMiddleware; + } else if (browserOptions.server) { + ssrMode = ServerSsrMode.InternalSsrMiddleware; + } + // Setup server and start listening const serverConfiguration = await setupServer( serverOptions, @@ -320,7 +336,7 @@ export async function* serveWithVite( assetFiles, browserOptions.preserveSymlinks, externalMetadata, - !!browserOptions.ssr, + ssrMode, prebundleTransformer, target, isZonelessApp(polyfills), @@ -334,12 +350,6 @@ export async function* serveWithVite( server = await createServer(serverConfiguration); await server.listen(); - if (browserOptions.ssr && serverOptions.prebundle !== false) { - // Warm up the SSR request and begin optimizing dependencies. - // Without this, Vite will only start optimizing SSR modules when the first request is made. - void server.warmupRequest('./main.server.mjs', { ssr: true }); - } - const urls = server.resolvedUrls; if (urls && (urls.local.length || urls.network.length)) { serverUrl = new URL(urls.local[0] ?? urls.network[0]); @@ -385,34 +395,37 @@ async function handleUpdate( usedComponentStyles: Map, ): Promise { const updatedFiles: string[] = []; - let isServerFileUpdated = false; + let destroyAngularServerAppCalled = false; // Invalidate any updated files - for (const [file, record] of generatedFiles) { - if (record.updated) { - updatedFiles.push(file); - isServerFileUpdated ||= record.type === BuildOutputFileType.ServerApplication; + for (const [file, { updated, type }] of generatedFiles) { + if (!updated) { + continue; + } - const updatedModules = server.moduleGraph.getModulesByFile( - normalizePath(join(server.config.root, file)), - ); - updatedModules?.forEach((m) => server?.moduleGraph.invalidateModule(m)); + if (type === BuildOutputFileType.ServerApplication && !destroyAngularServerAppCalled) { + // Clear the server app cache + // This must be done before module invalidation. + const { ɵdestroyAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')) as { + ɵdestroyAngularServerApp: typeof destroyAngularServerApp; + }; + + ɵdestroyAngularServerApp(); + destroyAngularServerAppCalled = true; } + + updatedFiles.push(file); + + const updatedModules = server.moduleGraph.getModulesByFile( + normalizePath(join(server.config.root, file)), + ); + updatedModules?.forEach((m) => server.moduleGraph.invalidateModule(m)); } if (!updatedFiles.length) { return; } - // clean server apps cache - if (isServerFileUpdated) { - const { ɵdestroyAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')) as { - ɵdestroyAngularServerApp: typeof destroyAngularServerApp; - }; - - ɵdestroyAngularServerApp(); - } - if (serverOptions.liveReload || serverOptions.hmr) { if (updatedFiles.every((f) => f.endsWith('.css'))) { const timestamp = Date.now(); @@ -534,7 +547,7 @@ export async function setupServer( assets: Map, preserveSymlinks: boolean | undefined, externalMetadata: DevServerExternalResultMetadata, - ssr: boolean, + ssrMode: ServerSsrMode, prebundleTransformer: JavaScriptTransformer, target: string[], zoneless: boolean, @@ -587,6 +600,9 @@ export async function setupServer( preserveSymlinks, }, server: { + warmup: { + ssrFiles: ['./main.server.mjs', './server.mjs'], + }, port: serverOptions.port, strictPort: true, host: serverOptions.host, @@ -637,19 +653,21 @@ export async function setupServer( }, plugins: [ createAngularLocaleDataPlugin(), - createAngularMemoryPlugin({ - workspaceRoot: serverOptions.workspaceRoot, - virtualProjectRoot, + createAngularSetupMiddlewaresPlugin({ outputFiles, assets, - ssr, - external: externalMetadata.explicitBrowser, indexHtmlTransformer, extensionMiddleware, - normalizePath, usedComponentStyles, + ssrMode, }), createRemoveIdPrefixPlugin(externalMetadata.explicitBrowser), + await createAngularSsrTransformPlugin(serverOptions.workspaceRoot), + await createAngularMemoryPlugin({ + virtualProjectRoot, + outputFiles, + external: externalMetadata.explicitBrowser, + }), ], // Browser only optimizeDeps. (This does not run for SSR dependencies). optimizeDeps: getDepOptimizationConfig({ diff --git a/packages/angular/build/src/tools/vite/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/angular-memory-plugin.ts deleted file mode 100644 index 3ceafc7fbd2d..000000000000 --- a/packages/angular/build/src/tools/vite/angular-memory-plugin.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @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 remapping, { SourceMapInput } from '@ampproject/remapping'; -import assert from 'node:assert'; -import { readFile } from 'node:fs/promises'; -import { dirname, join, relative } from 'node:path'; -import type { Connect, Plugin } from 'vite'; -import { - angularHtmlFallbackMiddleware, - createAngularAssetsMiddleware, - createAngularHeadersMiddleware, - createAngularIndexHtmlMiddleware, - createAngularSSRMiddleware, -} from './middlewares'; -import { AngularMemoryOutputFiles } from './utils'; - -export interface AngularMemoryPluginOptions { - workspaceRoot: string; - virtualProjectRoot: string; - outputFiles: AngularMemoryOutputFiles; - assets: Map; - ssr: boolean; - external?: string[]; - extensionMiddleware?: Connect.NextHandleFunction[]; - indexHtmlTransformer?: (content: string) => Promise; - normalizePath: (path: string) => string; - usedComponentStyles: Map; -} - -export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): Plugin { - const { - workspaceRoot, - virtualProjectRoot, - outputFiles, - assets, - external, - ssr, - extensionMiddleware, - indexHtmlTransformer, - normalizePath, - usedComponentStyles, - } = options; - - return { - name: 'vite:angular-memory', - // Ensures plugin hooks run before built-in Vite hooks - enforce: 'pre', - async resolveId(source, importer) { - // Prevent vite from resolving an explicit external dependency (`externalDependencies` option) - if (external?.includes(source)) { - // This is still not ideal since Vite will still transform the import specifier to - // `/@id/${source}` but is currently closer to a raw external than a resolved file path. - return source; - } - - if (importer && source[0] === '.' && normalizePath(importer).startsWith(virtualProjectRoot)) { - // Remove query if present - const [importerFile] = importer.split('?', 1); - - source = - '/' + normalizePath(join(dirname(relative(virtualProjectRoot, importerFile)), source)); - } - - const [file] = source.split('?', 1); - if (outputFiles.has(file)) { - return join(virtualProjectRoot, source); - } - }, - load(id) { - const [file] = id.split('?', 1); - const relativeFile = '/' + normalizePath(relative(virtualProjectRoot, file)); - const codeContents = outputFiles.get(relativeFile)?.contents; - if (codeContents === undefined) { - return relativeFile.endsWith('/node_modules/vite/dist/client/client.mjs') - ? loadViteClientCode(file) - : undefined; - } - - const code = Buffer.from(codeContents).toString('utf-8'); - const mapContents = outputFiles.get(relativeFile + '.map')?.contents; - - return { - // Remove source map URL comments from the code if a sourcemap is present. - // Vite will inline and add an additional sourcemap URL for the sourcemap. - code: mapContents ? code.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '') : code, - map: mapContents && Buffer.from(mapContents).toString('utf-8'), - }; - }, - // eslint-disable-next-line max-lines-per-function - configureServer(server) { - const originalssrTransform = server.ssrTransform; - server.ssrTransform = async (code, map, url, originalCode) => { - const result = await originalssrTransform(code, null, url, originalCode); - if (!result || !result.map || !map) { - return result; - } - - const remappedMap = remapping( - [result.map as SourceMapInput, map as SourceMapInput], - () => null, - ); - - // Set the sourcemap root to the workspace root. This is needed since we set a virtual path as root. - remappedMap.sourceRoot = normalizePath(workspaceRoot) + '/'; - - return { - ...result, - map: remappedMap as (typeof result)['map'], - }; - }; - - server.middlewares.use(createAngularHeadersMiddleware(server)); - - // Assets and resources get handled first - server.middlewares.use( - createAngularAssetsMiddleware(server, assets, outputFiles, usedComponentStyles), - ); - - if (extensionMiddleware?.length) { - extensionMiddleware.forEach((middleware) => server.middlewares.use(middleware)); - } - - // Returning a function, installs middleware after the main transform middleware but - // before the built-in HTML middleware - return () => { - if (ssr) { - server.middlewares.use(createAngularSSRMiddleware(server, indexHtmlTransformer)); - } - - server.middlewares.use(angularHtmlFallbackMiddleware); - - server.middlewares.use( - createAngularIndexHtmlMiddleware(server, outputFiles, indexHtmlTransformer), - ); - }; - }, - }; -} - -/** - * Reads the resolved Vite client code from disk and updates the content to remove - * an unactionable suggestion to update the Vite configuration file to disable the - * error overlay. The Vite configuration file is not present when used in the Angular - * CLI. - * @param file The absolute path to the Vite client code. - * @returns - */ -async function loadViteClientCode(file: string): Promise { - const originalContents = await readFile(file, 'utf-8'); - const updatedContents = originalContents.replace( - `"You can also disable this overlay by setting ", - h("code", { part: "config-option-name" }, "server.hmr.overlay"), - " to ", - h("code", { part: "config-option-value" }, "false"), - " in ", - h("code", { part: "config-file-name" }, hmrConfigName), - "."`, - '', - ); - - assert(originalContents !== updatedContents, 'Failed to update Vite client error overlay text.'); - - return updatedContents; -} diff --git a/packages/angular/build/src/tools/vite/middlewares/index.ts b/packages/angular/build/src/tools/vite/middlewares/index.ts index 924a18cd84ea..4fb4ad345cb7 100644 --- a/packages/angular/build/src/tools/vite/middlewares/index.ts +++ b/packages/angular/build/src/tools/vite/middlewares/index.ts @@ -9,5 +9,8 @@ export { createAngularAssetsMiddleware } from './assets-middleware'; export { angularHtmlFallbackMiddleware } from './html-fallback-middleware'; export { createAngularIndexHtmlMiddleware } from './index-html-middleware'; -export { createAngularSSRMiddleware } from './ssr-middleware'; +export { + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, +} from './ssr-middleware'; export { createAngularHeadersMiddleware } from './headers-middleware'; diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts index a45d28103160..e8b0a954e84b 100644 --- a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -6,18 +6,23 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { ɵgetOrCreateAngularServerApp as getOrCreateAngularServerApp } from '@angular/ssr'; +import type { + AngularAppEngine as SSRAngularAppEngine, + createRequestHandler, + ɵgetOrCreateAngularServerApp as getOrCreateAngularServerApp, +} from '@angular/ssr'; +import type { createNodeRequestHandler } from '@angular/ssr/node'; import type { ServerResponse } from 'node:http'; import type { Connect, ViteDevServer } from 'vite'; import { loadEsmModule } from '../../../utils/load-esm'; -export function createAngularSSRMiddleware( +export function createAngularSsrInternalMiddleware( server: ViteDevServer, indexHtmlTransformer?: (content: string) => Promise, ): Connect.NextHandleFunction { let cachedAngularServerApp: ReturnType | undefined; - return function angularSSRMiddleware( + return function angularSsrMiddleware( req: Connect.IncomingMessage, res: ServerResponse, next: Connect.NextFunction, @@ -26,10 +31,6 @@ export function createAngularSSRMiddleware( return next(); } - const resolvedUrls = server.resolvedUrls; - const baseUrl = resolvedUrls?.local[0] ?? resolvedUrls?.network[0]; - const url = new URL(req.url, baseUrl); - (async () => { const { writeResponseToNodeResponse, createWebRequestFromNodeRequest } = await loadEsmModule('@angular/ssr/node'); @@ -41,7 +42,7 @@ export function createAngularSSRMiddleware( const angularServerApp = ɵgetOrCreateAngularServerApp(); // Only Add the transform hook only if it's a different instance. if (cachedAngularServerApp !== angularServerApp) { - angularServerApp.hooks.on('html:transform:pre', async ({ html }) => { + angularServerApp.hooks.on('html:transform:pre', async ({ html, url }) => { const processedHtml = await server.transformIndexHtml(url.pathname, html); return indexHtmlTransformer?.(processedHtml) ?? processedHtml; @@ -62,3 +63,86 @@ export function createAngularSSRMiddleware( })().catch(next); }; } + +export async function createAngularSsrExternalMiddleware( + server: ViteDevServer, + indexHtmlTransformer?: (content: string) => Promise, +): Promise { + let fallbackWarningShown = false; + let cachedAngularAppEngine: typeof SSRAngularAppEngine | undefined; + let angularSsrInternalMiddleware: + | ReturnType + | undefined; + + const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } = + await loadEsmModule('@angular/ssr/node'); + + return function angularSsrExternalMiddleware( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, + ) { + (async () => { + const { default: handler, AngularAppEngine } = (await server.ssrLoadModule( + './server.mjs', + )) as { + default?: unknown; + AngularAppEngine: typeof SSRAngularAppEngine; + }; + + if (!isSsrNodeRequestHandler(handler) && !isSsrRequestHandler(handler)) { + if (!fallbackWarningShown) { + // eslint-disable-next-line no-console + console.warn( + `The default export in 'server.ts' does not provide a Node.js request handler. ` + + 'Using the internal SSR middleware instead.', + ); + fallbackWarningShown = true; + } + + angularSsrInternalMiddleware ??= createAngularSsrInternalMiddleware( + server, + indexHtmlTransformer, + ); + + angularSsrInternalMiddleware(req, res, next); + + return; + } + + if (cachedAngularAppEngine !== AngularAppEngine) { + AngularAppEngine.ɵhooks.on('html:transform:pre', async ({ html, url }) => { + const processedHtml = await server.transformIndexHtml(url.pathname, html); + + return indexHtmlTransformer?.(processedHtml) ?? processedHtml; + }); + + cachedAngularAppEngine = AngularAppEngine; + } + + // Forward the request to the middleware in server.ts + if (isSsrNodeRequestHandler(handler)) { + await handler(req, res, next); + } else { + const webRes = await handler(createWebRequestFromNodeRequest(req)); + if (!webRes) { + next(); + + return; + } + + await writeResponseToNodeResponse(webRes, res); + } + })().catch(next); + }; +} + +function isSsrNodeRequestHandler( + value: unknown, +): value is ReturnType { + return typeof value === 'function' && '__ng_node_request_handler__' in value; +} + +function isSsrRequestHandler(value: unknown): value is ReturnType { + return typeof value === 'function' && '__ng_request_handler__' in value; +} diff --git a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts new file mode 100644 index 000000000000..9d6510588ffa --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts @@ -0,0 +1,108 @@ +/** + * @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 assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { basename, dirname, join, relative } from 'node:path'; +import type { Plugin } from 'vite'; +import { loadEsmModule } from '../../../utils/load-esm'; +import { AngularMemoryOutputFiles } from '../utils'; + +interface AngularMemoryPluginOptions { + virtualProjectRoot: string; + outputFiles: AngularMemoryOutputFiles; + external?: string[]; +} + +export async function createAngularMemoryPlugin( + options: AngularMemoryPluginOptions, +): Promise { + const { virtualProjectRoot, outputFiles, external } = options; + const { normalizePath } = await loadEsmModule('vite'); + // See: https://github.com/vitejs/vite/blob/a34a73a3ad8feeacc98632c0f4c643b6820bbfda/packages/vite/src/node/server/pluginContainer.ts#L331-L334 + const defaultImporter = join(virtualProjectRoot, 'index.html'); + + return { + name: 'vite:angular-memory', + // Ensures plugin hooks run before built-in Vite hooks + enforce: 'pre', + async resolveId(source, importer) { + // Prevent vite from resolving an explicit external dependency (`externalDependencies` option) + if (external?.includes(source)) { + // This is still not ideal since Vite will still transform the import specifier to + // `/@id/${source}` but is currently closer to a raw external than a resolved file path. + return source; + } + + if (importer) { + let normalizedSource: string | undefined; + if (source[0] === '.' && normalizePath(importer).startsWith(virtualProjectRoot)) { + // Remove query if present + const [importerFile] = importer.split('?', 1); + normalizedSource = join(dirname(relative(virtualProjectRoot, importerFile)), source); + } else if (source[0] === '/' && importer === defaultImporter) { + normalizedSource = basename(source); + } + if (normalizedSource) { + source = '/' + normalizePath(normalizedSource); + } + } + + const [file] = source.split('?', 1); + if (outputFiles.has(file)) { + return join(virtualProjectRoot, source); + } + }, + load(id) { + const [file] = id.split('?', 1); + const relativeFile = '/' + normalizePath(relative(virtualProjectRoot, file)); + const codeContents = outputFiles.get(relativeFile)?.contents; + if (codeContents === undefined) { + return relativeFile.endsWith('/node_modules/vite/dist/client/client.mjs') + ? loadViteClientCode(file) + : undefined; + } + + const code = Buffer.from(codeContents).toString('utf-8'); + const mapContents = outputFiles.get(relativeFile + '.map')?.contents; + + return { + // Remove source map URL comments from the code if a sourcemap is present. + // Vite will inline and add an additional sourcemap URL for the sourcemap. + code: mapContents ? code.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '') : code, + map: mapContents && Buffer.from(mapContents).toString('utf-8'), + }; + }, + }; +} + +/** + * Reads the resolved Vite client code from disk and updates the content to remove + * an unactionable suggestion to update the Vite configuration file to disable the + * error overlay. The Vite configuration file is not present when used in the Angular + * CLI. + * @param file The absolute path to the Vite client code. + * @returns + */ +async function loadViteClientCode(file: string): Promise { + const originalContents = await readFile(file, 'utf-8'); + const updatedContents = originalContents.replace( + `"You can also disable this overlay by setting ", + h("code", { part: "config-option-name" }, "server.hmr.overlay"), + " to ", + h("code", { part: "config-option-value" }, "false"), + " in ", + h("code", { part: "config-file-name" }, hmrConfigName), + "."`, + '', + ); + + assert(originalContents !== updatedContents, 'Failed to update Vite client error overlay text.'); + + return updatedContents; +} diff --git a/packages/angular/build/src/tools/vite/i18n-locale-plugin.ts b/packages/angular/build/src/tools/vite/plugins/i18n-locale-plugin.ts similarity index 96% rename from packages/angular/build/src/tools/vite/i18n-locale-plugin.ts rename to packages/angular/build/src/tools/vite/plugins/i18n-locale-plugin.ts index 6b6eec3c0b4d..5cf3762245a5 100644 --- a/packages/angular/build/src/tools/vite/i18n-locale-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/i18n-locale-plugin.ts @@ -11,7 +11,7 @@ import type { Plugin } from 'vite'; /** * The base module location used to search for locale specific data. */ -export const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; +const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; /** * Creates a Vite plugin that resolves Angular locale data files from `@angular/common`. diff --git a/packages/angular/build/src/tools/vite/id-prefix-plugin.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts similarity index 100% rename from packages/angular/build/src/tools/vite/id-prefix-plugin.ts rename to packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts diff --git a/packages/angular/build/src/tools/vite/plugins/index.ts b/packages/angular/build/src/tools/vite/plugins/index.ts new file mode 100644 index 000000000000..50a6ab6aa7c9 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/index.ts @@ -0,0 +1,13 @@ +/** + * @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 + */ + +export { createAngularMemoryPlugin } from './angular-memory-plugin'; +export { createAngularLocaleDataPlugin } from './i18n-locale-plugin'; +export { createRemoveIdPrefixPlugin } from './id-prefix-plugin'; +export { createAngularSetupMiddlewaresPlugin, ServerSsrMode } from './setup-middlewares-plugin'; +export { createAngularSsrTransformPlugin } from './ssr-transform-plugin'; diff --git a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts new file mode 100644 index 000000000000..3f8611223c1c --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts @@ -0,0 +1,101 @@ +/** + * @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 type { Connect, Plugin } from 'vite'; +import { + angularHtmlFallbackMiddleware, + createAngularAssetsMiddleware, + createAngularHeadersMiddleware, + createAngularIndexHtmlMiddleware, + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, +} from '../middlewares'; +import { AngularMemoryOutputFiles } from '../utils'; + +export enum ServerSsrMode { + /** + * No SSR + */ + NoSsr, + + /** + * Internal server-side rendering (SSR) is handled through the built-in middleware. + * + * In this mode, the SSR process is managed internally by the dev-server's middleware. + * The server automatically renders pages on the server without requiring external + * middleware or additional configuration from the developer. + */ + InternalSsrMiddleware, + + /** + * External server-side rendering (SSR) is handled by a custom middleware defined in server.ts. + * + * This mode allows developers to define custom SSR behavior by providing a middleware in the + * `server.ts` file. It gives more flexibility for handling SSR, such as integrating with other + * frameworks or customizing the rendering pipeline. + */ + ExternalSsrMiddleware, +} + +interface AngularSetupMiddlewaresPluginOptions { + outputFiles: AngularMemoryOutputFiles; + assets: Map; + extensionMiddleware?: Connect.NextHandleFunction[]; + indexHtmlTransformer?: (content: string) => Promise; + usedComponentStyles: Map; + ssrMode: ServerSsrMode; +} + +export function createAngularSetupMiddlewaresPlugin( + options: AngularSetupMiddlewaresPluginOptions, +): Plugin { + return { + name: 'vite:angular-setup-middlewares', + enforce: 'pre', + configureServer(server) { + const { + indexHtmlTransformer, + outputFiles, + extensionMiddleware, + assets, + usedComponentStyles, + ssrMode, + } = options; + + // Headers, assets and resources get handled first + server.middlewares.use(createAngularHeadersMiddleware(server)); + server.middlewares.use( + createAngularAssetsMiddleware(server, assets, outputFiles, usedComponentStyles), + ); + + extensionMiddleware?.forEach((middleware) => server.middlewares.use(middleware)); + + // Returning a function, installs middleware after the main transform middleware but + // before the built-in HTML middleware + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return async () => { + if (ssrMode === ServerSsrMode.ExternalSsrMiddleware) { + server.middlewares.use( + await createAngularSsrExternalMiddleware(server, indexHtmlTransformer), + ); + + return; + } + + if (ssrMode === ServerSsrMode.InternalSsrMiddleware) { + server.middlewares.use(createAngularSsrInternalMiddleware(server, indexHtmlTransformer)); + } + + server.middlewares.use(angularHtmlFallbackMiddleware); + server.middlewares.use( + createAngularIndexHtmlMiddleware(server, outputFiles, indexHtmlTransformer), + ); + }; + }, + }; +} diff --git a/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts b/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts new file mode 100644 index 000000000000..0b164dee5b46 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts @@ -0,0 +1,43 @@ +/** + * @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 remapping, { SourceMapInput } from '@ampproject/remapping'; +import type { Plugin } from 'vite'; +import { loadEsmModule } from '../../../utils/load-esm'; + +export async function createAngularSsrTransformPlugin(workspaceRoot: string): Promise { + const { normalizePath } = await loadEsmModule('vite'); + + return { + name: 'vite:angular-ssr-transform', + enforce: 'pre', + async configureServer(server) { + const originalssrTransform = server.ssrTransform; + + server.ssrTransform = async (code, map, url, originalCode) => { + const result = await originalssrTransform(code, null, url, originalCode); + if (!result || !result.map || !map) { + return result; + } + + const remappedMap = remapping( + [result.map as SourceMapInput, map as SourceMapInput], + () => null, + ); + + // Set the sourcemap root to the workspace root. This is needed since we set a virtual path as root. + remappedMap.sourceRoot = normalizePath(workspaceRoot) + '/'; + + return { + ...result, + map: remappedMap as (typeof result)['map'], + }; + }; + }, + }; +} diff --git a/packages/angular/ssr/node/public_api.ts b/packages/angular/ssr/node/public_api.ts index 721e6a4cf215..a08d88d2738e 100644 --- a/packages/angular/ssr/node/public_api.ts +++ b/packages/angular/ssr/node/public_api.ts @@ -14,6 +14,7 @@ export { export { AngularNodeAppEngine } from './src/app-engine'; +export { createNodeRequestHandler } from './src/handler'; export { writeResponseToNodeResponse } from './src/response'; export { createWebRequestFromNodeRequest } from './src/request'; export { isMainModule } from './src/module'; diff --git a/packages/angular/ssr/node/src/handler.ts b/packages/angular/ssr/node/src/handler.ts new file mode 100644 index 000000000000..40fedb46e0f6 --- /dev/null +++ b/packages/angular/ssr/node/src/handler.ts @@ -0,0 +1,74 @@ +/** + * @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 type { IncomingMessage, ServerResponse } from 'node:http'; + +/** + * Represents a middleware function for handling HTTP requests in a Node.js environment. + * + * @param req - The incoming HTTP request object. + * @param res - The outgoing HTTP response object. + * @param next - A callback function that signals the completion of the middleware or forwards the error if provided. + * + * @returns A Promise that resolves to void or simply void. The handler can be asynchronous. + */ +type RequestHandlerFunction = ( + req: IncomingMessage, + res: ServerResponse, + next: (err?: unknown) => void, +) => Promise | void; + +/** + * Attaches metadata to the handler function to mark it as a special handler for Node.js environments. + * + * @typeParam T - The type of the handler function. + * @param handler - The handler function to be defined and annotated. + * @returns The same handler function passed as an argument, with metadata attached. + * + * @example + * Usage in an Express application: + * ```ts + * const app = express(); + * export default createNodeRequestHandler(app); + * ``` + * + * @example + * Usage in a Hono application: + * ```ts + * const app = new Hono(); + * export default createNodeRequestHandler(async (req, res, next) => { + * try { + * const webRes = await app.fetch(createWebRequestFromNodeRequest(req)); + * if (webRes) { + * await writeResponseToNodeResponse(webRes, res); + * } else { + * next(); + * } + * } catch (error) { + * next(error); + * } + * })); + * ``` + * + * @example + * Usage in a Fastify application: + * ```ts + * const app = Fastify(); + * export default createNodeRequestHandler(async (req, res) => { + * await app.ready(); + * app.server.emit('request', req, res); + * res.send('Hello from Fastify with Node Next Handler!'); + * })); + * ``` + * @developerPreview + */ +export function createNodeRequestHandler(handler: T): T { + (handler as T & { __ng_node_request_handler__?: boolean })['__ng_node_request_handler__'] = true; + + return handler; +} diff --git a/packages/angular/ssr/public_api.ts b/packages/angular/ssr/public_api.ts index 86856b065309..256a0169c89b 100644 --- a/packages/angular/ssr/public_api.ts +++ b/packages/angular/ssr/public_api.ts @@ -9,6 +9,7 @@ export * from './private_export'; export { AngularAppEngine } from './src/app-engine'; +export { createRequestHandler } from './src/handler'; export { type PrerenderFallback, diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index d01b51bc4f60..f2a5e3bebd2d 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -178,25 +178,26 @@ export class AngularServerApp { }), }; - if (renderMode === RenderMode.Client) { + if (renderMode === RenderMode.Server) { + // Configure platform providers for request and response only for SSR. + platformProviders.push( + { + provide: REQUEST, + useValue: request, + }, + { + provide: REQUEST_CONTEXT, + useValue: requestContext, + }, + { + provide: RESPONSE_INIT, + useValue: responseInit, + }, + ); + } else if (renderMode === RenderMode.Client) { // Serve the client-side rendered version if the route is configured for CSR. return new Response(await this.assets.getServerAsset('index.csr.html'), responseInit); } - - platformProviders.push( - { - provide: REQUEST, - useValue: request, - }, - { - provide: REQUEST_CONTEXT, - useValue: requestContext, - }, - { - provide: RESPONSE_INIT, - useValue: responseInit, - }, - ); } const { @@ -215,7 +216,7 @@ export class AngularServerApp { let html = await assets.getIndexServerHtml(); // Skip extra microtask if there are no pre hooks. if (hooks.has('html:transform:pre')) { - html = await hooks.run('html:transform:pre', { html }); + html = await hooks.run('html:transform:pre', { html, url }); } this.boostrap ??= await bootstrap(); @@ -223,7 +224,7 @@ export class AngularServerApp { html = await renderAngular( html, this.boostrap, - new URL(request.url), + url, platformProviders, SERVER_CONTEXT_VALUE[renderMode], ); diff --git a/packages/angular/ssr/src/handler.ts b/packages/angular/ssr/src/handler.ts new file mode 100644 index 000000000000..16425ff3ccb2 --- /dev/null +++ b/packages/angular/ssr/src/handler.ts @@ -0,0 +1,47 @@ +/** + * @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 + */ + +/** + * Function for handling HTTP requests in a web environment. + * + * @param request - The incoming HTTP request object. + * @returns A Promise resolving to a `Response` object, `null`, or directly a `Response`, + * supporting both synchronous and asynchronous handling. + */ +type RequestHandlerFunction = (request: Request) => Promise | null | Response; + +/** + * Annotates a request handler function with metadata, marking it as a special + * handler. + * + * @param handler - The request handler function to be annotated. + * @returns The same handler function passed in, with metadata attached. + * + * @example + * Example usage in a Hono application: + * ```ts + * const app = new Hono(); + * export default createRequestHandler(app.fetch); + * ``` + * + * @example + * Example usage in a H3 application: + * ```ts + * const app = createApp(); + * const handler = toWebHandler(app); + * export default createRequestHandler(handler); + * ``` + * @developerPreview + */ +export function createRequestHandler(handler: RequestHandlerFunction): RequestHandlerFunction { + (handler as RequestHandlerFunction & { __ng_request_handler__?: boolean })[ + '__ng_request_handler__' + ] = true; + + return handler; +} diff --git a/packages/angular/ssr/src/hooks.ts b/packages/angular/ssr/src/hooks.ts index ab34973583b8..9b2209922fab 100644 --- a/packages/angular/ssr/src/hooks.ts +++ b/packages/angular/ssr/src/hooks.ts @@ -7,13 +7,13 @@ */ /** - * Handler function type for HTML transformation hooks. - * It takes an object containing the HTML content to be modified. + * Defines a handler function type for transforming HTML content. + * This function receives an object with the HTML to be processed. * - * @param ctx - The context object containing the HTML content. - * @returns The modified HTML content or a promise that resolves to the modified HTML content. + * @param ctx - An object containing the URL and HTML content to be transformed. + * @returns The transformed HTML as a string or a promise that resolves to the transformed HTML. */ -type HtmlTransformHandler = (ctx: { html: string }) => string | Promise; +type HtmlTransformHandler = (ctx: { url: URL; html: string }) => string | Promise; /** * Defines the names of available hooks for registering and triggering custom logic within the application. diff --git a/packages/angular/ssr/test/hooks_spec.ts b/packages/angular/ssr/test/hooks_spec.ts index ea2e32f87590..7d27b2a7efdc 100644 --- a/packages/angular/ssr/test/hooks_spec.ts +++ b/packages/angular/ssr/test/hooks_spec.ts @@ -10,6 +10,7 @@ import { Hooks } from '../src/hooks'; describe('Hooks', () => { let hooks: Hooks & { run: Function }; + const url = new URL('http://example.com/'); beforeEach(() => { hooks = new Hooks() as Hooks & { run: Function }; @@ -33,12 +34,12 @@ describe('Hooks', () => { hooks.on('html:transform:pre', ({ html }) => html + '1'); hooks.on('html:transform:pre', ({ html }) => html + '2'); - const result = await hooks.run('html:transform:pre', { html: 'start' }); + const result = await hooks.run('html:transform:pre', { html: 'start', url }); expect(result).toBe('start12'); }); it('should return the context html if no hooks are registered', async () => { - const result = await hooks.run('html:transform:pre', { html: 'start' }); + const result = await hooks.run('html:transform:pre', { html: 'start', url }); expect(result).toBe('start'); }); diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts new file mode 100644 index 000000000000..37166e8c2a12 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-express.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + const options = appArchitect.build.options; + options.outputMode = 'server'; + }); + + await writeMultipleFiles({ + // Replace the template of app.component.html as it makes it harder to debug + 'src/app/app.component.html': '', + 'src/app/app.config.server.ts': ` + import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; + import { provideServerRendering } from '@angular/platform-server'; + import { provideServerRoutesConfig } from '@angular/ssr'; + import { routes } from './app.routes.server'; + import { appConfig } from './app.config'; + + const serverConfig: ApplicationConfig = { + providers: [ + provideServerRoutesConfig(routes), + provideServerRendering() + ] + }; + + export const config = mergeApplicationConfig(appConfig, serverConfig); + `, + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + + export const routes: Routes = [ + { path: 'home', component: HomeComponent } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import express from 'express'; + import { fileURLToPath } from 'node:url'; + import { dirname, resolve } from 'node:path'; + + export function app(): express.Express { + const server = express(); + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const browserDistFolder = resolve(serverDistFolder, '../browser'); + const angularNodeAppEngine = new AngularNodeAppEngine(); + + server.use('/api/**', (req, res) => res.json({ hello: 'foo' })); + + server.get('**', express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html' + })); + + server.get('**', (req, res, next) => { + angularNodeAppEngine.render(req) + .then((response) => response ? writeResponseToNodeResponse(response, res) : next()) + .catch(next); + }); + + return server; + } + + const server = app(); + if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + server.listen(port, () => { + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + export default createNodeRequestHandler(server); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.component.html', + 'home works', + 'yay home works!!!', + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch(/Page reload sent to client/), + replaceInFile(filePath, searchValue, replaceValue), + ]); + + await setTimeout(200); +} diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts new file mode 100644 index 000000000000..f1de8f471b8a --- /dev/null +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-fastify.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('fastify@5'); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + const options = appArchitect.build.options; + options.outputMode = 'server'; + }); + + await writeMultipleFiles({ + // Replace the template of app.component.html as it makes it harder to debug + 'src/app/app.component.html': '', + 'src/app/app.config.server.ts': ` + import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; + import { provideServerRendering } from '@angular/platform-server'; + import { provideServerRoutesConfig } from '@angular/ssr'; + import { routes } from './app.routes.server'; + import { appConfig } from './app.config'; + + const serverConfig: ApplicationConfig = { + providers: [ + provideServerRoutesConfig(routes), + provideServerRendering() + ] + }; + + export const config = mergeApplicationConfig(appConfig, serverConfig); + `, + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + + export const routes: Routes = [ + { path: 'home', component: HomeComponent } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import fastify from 'fastify'; + + export function app() { + const server = fastify(); + const angularNodeAppEngine = new AngularNodeAppEngine(); + server.get('/api/*', (req, reply) => reply.send({ hello: 'foo' })); + server.get('*', async (req, reply) => { + try { + const response = await angularNodeAppEngine.render(req.raw); + if (response) { + await writeResponseToNodeResponse(response, reply.raw); + } else { + reply.callNotFound(); + } + } catch (error) { + reply.send(error); + } + }); + + return server; + } + + const server = app(); + if (isMainModule(import.meta.url)) { + const port = +(process.env['PORT'] || 4000); + server.listen({ port }, () => { + console.log(\`Fastify server listening on http://localhost:\${port}\`); + }); + } + + export default createNodeRequestHandler(async (req, res) => { + await server.ready(); + server.server.emit('request', req, res); + }); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.component.html', + 'home works', + 'yay home works!!!', + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch(/Page reload sent to client/), + replaceInFile(filePath, searchValue, replaceValue), + ]); + + await setTimeout(200); +} diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts new file mode 100644 index 000000000000..7f364ecd3d3c --- /dev/null +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-h3.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('h3@1'); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + const options = appArchitect.build.options; + options.outputMode = 'server'; + }); + + await writeMultipleFiles({ + // Replace the template of app.component.html as it makes it harder to debug + 'src/app/app.component.html': '', + 'src/app/app.config.server.ts': ` + import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; + import { provideServerRendering } from '@angular/platform-server'; + import { provideServerRoutesConfig } from '@angular/ssr'; + import { routes } from './app.routes.server'; + import { appConfig } from './app.config'; + + const serverConfig: ApplicationConfig = { + providers: [ + provideServerRoutesConfig(routes), + provideServerRendering() + ] + }; + + export const config = mergeApplicationConfig(appConfig, serverConfig); + `, + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + + export const routes: Routes = [ + { path: 'home', component: HomeComponent } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { createApp, createRouter, toWebHandler, defineEventHandler, toWebRequest } from 'h3'; + + export function app() { + const server = createApp(); + const router = createRouter(); + const angularAppEngine = new AngularAppEngine(); + + router.use( + '/api/**', + defineEventHandler(() => ({ hello: 'foo' })), + ); + + router.use( + '/**', + defineEventHandler((event) => angularAppEngine.render(toWebRequest(event))), + ); + + server.use(router); + + return server; + } + + const server = app(); + const handler = toWebHandler(server); + export default createRequestHandler(handler); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.component.html', + 'home works', + 'yay home works!!!', + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch(/Page reload sent to client/), + replaceInFile(filePath, searchValue, replaceValue), + ]); + + await setTimeout(200); +} diff --git a/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts b/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts new file mode 100644 index 000000000000..83c099db9426 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/vite/ssr-entry-hono.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('hono@4'); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + const options = appArchitect.build.options; + options.outputMode = 'server'; + }); + + await writeMultipleFiles({ + // Replace the template of app.component.html as it makes it harder to debug + 'src/app/app.component.html': '', + 'src/app/app.config.server.ts': ` + import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; + import { provideServerRendering } from '@angular/platform-server'; + import { provideServerRoutesConfig } from '@angular/ssr'; + import { routes } from './app.routes.server'; + import { appConfig } from './app.config'; + + const serverConfig: ApplicationConfig = { + providers: [ + provideServerRoutesConfig(routes), + provideServerRendering() + ] + }; + + export const config = mergeApplicationConfig(appConfig, serverConfig); + `, + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { HomeComponent } from './home/home.component'; + + export const routes: Routes = [ + { path: 'home', component: HomeComponent } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const routes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { Hono } from 'hono'; + + export function app() { + const server = new Hono(); + const angularAppEngine = new AngularAppEngine(); + + server.get('/api/*', (c) => c.json({ hello: 'foo' })); + server.get('/*', async (c) => { + const res = await angularAppEngine.render(c.req.raw); + return res || undefined + }); + + return server; + } + + const server = app(); + export default createRequestHandler(server.fetch); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.component.html', + 'home works', + 'yay home works!!!', + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch(/Page reload sent to client/), + replaceInFile(filePath, searchValue, replaceValue), + ]); + + await setTimeout(200); +}