From eee2505baafb557d70414cbf4328707ed6c0c5b4 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 14 Oct 2024 16:42:29 -0400 Subject: [PATCH] refactor(@angular/build): add component update middleware to development server An additional development server middleware has been added that responds to client `/@ng/component` requests from the Angular framework for hot component template updates. These client requests are made by the framework after being triggered by the development server sending a `angular:component-update` WebSocket event. The Angular compiler's new internal `_enableHmr` option will emit template replacement and reloading code that uses this new development server update middleware. Within the development server, the component update build result will be used to indicate that an event should be sent to active clients. The build system does not yet generate component update results. --- .../build/src/builders/application/results.ts | 8 +-- .../tests/behavior/component-updates_spec.ts | 51 +++++++++++++++++++ .../src/builders/dev-server/vite-server.ts | 27 ++++++++-- .../vite/middlewares/component-middleware.ts | 42 +++++++++++++++ .../build/src/tools/vite/middlewares/index.ts | 1 + .../vite/plugins/setup-middlewares-plugin.ts | 4 ++ 6 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 packages/angular/build/src/builders/dev-server/tests/behavior/component-updates_spec.ts create mode 100644 packages/angular/build/src/tools/vite/middlewares/component-middleware.ts diff --git a/packages/angular/build/src/builders/application/results.ts b/packages/angular/build/src/builders/application/results.ts index 165315c2657b..842af17dda3f 100644 --- a/packages/angular/build/src/builders/application/results.ts +++ b/packages/angular/build/src/builders/application/results.ts @@ -68,7 +68,9 @@ export interface ResultMessage { export interface ComponentUpdateResult extends BaseResult { kind: ResultKind.ComponentUpdate; - id: string; - type: 'style' | 'template'; - content: string; + updates: { + id: string; + type: 'style' | 'template'; + content: string; + }[]; } diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/component-updates_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/component-updates_spec.ts new file mode 100644 index 000000000000..d471d487c556 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/component-updates_spec.ts @@ -0,0 +1,51 @@ +/** + * @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 { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + describe('Behavior: "Component updates"', () => { + beforeEach(async () => { + setupTarget(harness, {}); + + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', 'console.log("foo");'); + }); + + it('responds with a 400 status if no request component query is present', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/@ng/component'); + + expect(result?.success).toBeTrue(); + expect(response?.status).toBe(400); + }); + + it('responds with an empty JS file when no component update is available', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + const { result, response } = await executeOnceAndFetch( + harness, + '/@ng/component?c=src%2Fapp%2Fapp.component.ts%40AppComponent', + ); + + expect(result?.success).toBeTrue(); + expect(response?.status).toBe(200); + const output = await response?.text(); + expect(response?.headers.get('Content-Type')).toEqual('text/javascript'); + expect(response?.headers.get('Cache-Control')).toEqual('no-cache'); + expect(output).toBe(''); + }); + }); +}); 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 0a059ce53fc8..d65ca93ff450 100644 --- a/packages/angular/build/src/builders/dev-server/vite-server.ts +++ b/packages/angular/build/src/builders/dev-server/vite-server.ts @@ -167,6 +167,7 @@ export async function* serveWithVite( explicitServer: [], }; const usedComponentStyles = new Map(); + const templateUpdates = new Map(); // Add cleanup logic via a builder teardown. let deferred: () => void; @@ -211,6 +212,9 @@ export async function* serveWithVite( assetFiles.set('/' + normalizePath(outputPath), normalizePath(file.inputPath)); } } + // Clear stale template updates on a code rebuilds + templateUpdates.clear(); + // Analyze result files for changes analyzeResultFiles(normalizePath, htmlIndexPath, result.files, generatedFiles); break; @@ -220,8 +224,22 @@ export async function* serveWithVite( break; case ResultKind.ComponentUpdate: assert(serverOptions.hmr, 'Component updates are only supported with HMR enabled.'); - // TODO: Implement support -- application builder currently does not use - break; + assert( + server, + 'Builder must provide an initial full build before component update results.', + ); + + for (const componentUpdate of result.updates) { + if (componentUpdate.type === 'template') { + templateUpdates.set(componentUpdate.id, componentUpdate.content); + server.ws.send('angular:component-update', { + id: componentUpdate.id, + timestamp: Date.now(), + }); + } + } + context.logger.info('Component update sent to client(s).'); + continue; default: context.logger.warn(`Unknown result kind [${(result as Result).kind}] provided by build.`); continue; @@ -353,6 +371,7 @@ export async function* serveWithVite( target, isZonelessApp(polyfills), usedComponentStyles, + templateUpdates, browserOptions.loader as EsbuildLoaderOption | undefined, extensions?.middleware, transformers?.indexHtml, @@ -460,7 +479,7 @@ async function handleUpdate( } return { - type: 'css-update', + type: 'css-update' as const, timestamp, path: filePath, acceptedPath: filePath, @@ -564,6 +583,7 @@ export async function setupServer( target: string[], zoneless: boolean, usedComponentStyles: Map, + templateUpdates: Map, prebundleLoaderExtensions: EsbuildLoaderOption | undefined, extensionMiddleware?: Connect.NextHandleFunction[], indexHtmlTransformer?: (content: string) => Promise, @@ -671,6 +691,7 @@ export async function setupServer( indexHtmlTransformer, extensionMiddleware, usedComponentStyles, + templateUpdates, ssrMode, }), createRemoveIdPrefixPlugin(externalMetadata.explicitBrowser), diff --git a/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts new file mode 100644 index 000000000000..abfd330dec90 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts @@ -0,0 +1,42 @@ +/** + * @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 } from 'vite'; + +const ANGULAR_COMPONENT_PREFIX = '/@ng/component'; + +export function createAngularComponentMiddleware( + templateUpdates: ReadonlyMap, +): Connect.NextHandleFunction { + return function angularComponentMiddleware(req, res, next) { + if (req.url === undefined || res.writableEnded) { + return; + } + + if (!req.url.startsWith(ANGULAR_COMPONENT_PREFIX)) { + next(); + + return; + } + + const requestUrl = new URL(req.url, 'http://localhost'); + const componentId = requestUrl.searchParams.get('c'); + if (!componentId) { + res.statusCode = 400; + res.end(); + + return; + } + + const updateCode = templateUpdates.get(componentId) ?? ''; + + res.setHeader('Content-Type', 'text/javascript'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(updateCode); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/index.ts b/packages/angular/build/src/tools/vite/middlewares/index.ts index 4fb4ad345cb7..fb5416c07e7e 100644 --- a/packages/angular/build/src/tools/vite/middlewares/index.ts +++ b/packages/angular/build/src/tools/vite/middlewares/index.ts @@ -14,3 +14,4 @@ export { createAngularSsrInternalMiddleware, } from './ssr-middleware'; export { createAngularHeadersMiddleware } from './headers-middleware'; +export { createAngularComponentMiddleware } from './component-middleware'; 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 index 3f8611223c1c..81459aff4312 100644 --- a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts +++ b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts @@ -10,6 +10,7 @@ import type { Connect, Plugin } from 'vite'; import { angularHtmlFallbackMiddleware, createAngularAssetsMiddleware, + createAngularComponentMiddleware, createAngularHeadersMiddleware, createAngularIndexHtmlMiddleware, createAngularSsrExternalMiddleware, @@ -48,6 +49,7 @@ interface AngularSetupMiddlewaresPluginOptions { extensionMiddleware?: Connect.NextHandleFunction[]; indexHtmlTransformer?: (content: string) => Promise; usedComponentStyles: Map; + templateUpdates: Map; ssrMode: ServerSsrMode; } @@ -64,11 +66,13 @@ export function createAngularSetupMiddlewaresPlugin( extensionMiddleware, assets, usedComponentStyles, + templateUpdates, ssrMode, } = options; // Headers, assets and resources get handled first server.middlewares.use(createAngularHeadersMiddleware(server)); + server.middlewares.use(createAngularComponentMiddleware(templateUpdates)); server.middlewares.use( createAngularAssetsMiddleware(server, assets, outputFiles, usedComponentStyles), );