diff --git a/packages/angular/build/src/builders/dev-server/options.ts b/packages/angular/build/src/builders/dev-server/options.ts index 5473da832449..fc19e9357ece 100644 --- a/packages/angular/build/src/builders/dev-server/options.ts +++ b/packages/angular/build/src/builders/dev-server/options.ts @@ -115,6 +115,7 @@ export async function normalizeOptions( sslKey, prebundle, allowedHosts, + middlewareConfig, } = options; // Return all the normalized options @@ -142,5 +143,6 @@ export async function normalizeOptions( prebundle: cacheOptions.enabled && !optimization.scripts && prebundle, inspect, allowedHosts: allowedHosts ? allowedHosts : [], + middlewareConfig, }; } diff --git a/packages/angular/build/src/builders/dev-server/schema.json b/packages/angular/build/src/builders/dev-server/schema.json index 023478ff7e52..f50d0b7742de 100644 --- a/packages/angular/build/src/builders/dev-server/schema.json +++ b/packages/angular/build/src/builders/dev-server/schema.json @@ -110,29 +110,43 @@ "type": "string", "description": "Activate the inspector on host and port in the format of `[[host:]port]`. See the security warning in https://nodejs.org/docs/latest-v22.x/api/cli.html#warning-binding-inspector-to-a-public-ipport-combination-is-insecure regarding the host parameter usage." }, - { "type": "boolean" } + { + "type": "boolean" + } ] }, "prebundle": { "description": "Enable and control the Vite-based development server's prebundling capabilities. To enable prebundling, the Angular CLI cache must also be enabled.", "default": true, "oneOf": [ - { "type": "boolean" }, + { + "type": "boolean" + }, { "type": "object", "properties": { "exclude": { "description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself. Note: specifying `@foo/bar` marks all paths within the `@foo/bar` package as excluded, including sub-paths like `@foo/bar/baz`.", "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } } }, "additionalProperties": false, - "required": ["exclude"] + "required": [ + "exclude" + ] } ] + }, + "middlewareConfig": { + "type": "string", + "description": "Middleware configuration file." } }, "additionalProperties": false, - "required": ["buildTarget"] -} + "required": [ + "buildTarget" + ] +} \ No newline at end of file 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 73f58ad5c348..a139d05153ca 100644 --- a/packages/angular/build/src/builders/dev-server/vite/server.ts +++ b/packages/angular/build/src/builders/dev-server/vite/server.ts @@ -19,7 +19,7 @@ import { createRemoveIdPrefixPlugin, } from '../../../tools/vite/plugins'; import { EsbuildLoaderOption, getDepOptimizationConfig } from '../../../tools/vite/utils'; -import { loadProxyConfiguration } from '../../../utils'; +import { loadMiddlewareConfiguration, loadProxyConfiguration } from '../../../utils'; import { type ApplicationBuilderInternalOptions, JavaScriptTransformer } from '../internal'; import type { NormalizedDevServerOptions } from '../options'; import { DevServerExternalResultMetadata, OutputAssetRecord, OutputFileRecord } from './utils'; @@ -153,6 +153,19 @@ export async function setupServer( join(serverOptions.workspaceRoot, `.angular/vite-root`, serverOptions.buildTarget.project), ); + if (serverOptions.middlewareConfig) { + const middleware = await loadMiddlewareConfiguration( + serverOptions.workspaceRoot, + serverOptions.middlewareConfig, + ); + + if (extensionMiddleware) { + extensionMiddleware = [...extensionMiddleware, ...middleware]; + } else { + extensionMiddleware = middleware; + } + } + /** * Required when using `externalDependencies` to prevent Vite load errors. * diff --git a/packages/angular/build/src/utils/index.ts b/packages/angular/build/src/utils/index.ts index 1a7cb15cd9c3..e75a8f341b48 100644 --- a/packages/angular/build/src/utils/index.ts +++ b/packages/angular/build/src/utils/index.ts @@ -10,3 +10,4 @@ export * from './normalize-asset-patterns'; export * from './normalize-optimization'; export * from './normalize-source-maps'; export * from './load-proxy-config'; +export * from './load-middleware-config'; diff --git a/packages/angular/build/src/utils/load-middleware-config.ts b/packages/angular/build/src/utils/load-middleware-config.ts new file mode 100644 index 000000000000..0912865f14d4 --- /dev/null +++ b/packages/angular/build/src/utils/load-middleware-config.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 { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { Connect } from 'vite'; +import { assertIsError } from './error'; +import { loadEsmModule } from './load-esm'; + +export async function loadMiddlewareConfiguration( + root: string, + middlewareConfig: string | undefined, +): Promise { + if (!middlewareConfig) { + return []; + } + + const middlewarePath = resolve(root, middlewareConfig); + + if (!existsSync(middlewarePath)) { + throw new Error(`Middleware configuration file ${middlewarePath} does not exist.`); + } + + let middlewareConfiguration; + + try { + middlewareConfiguration = await import(pathToFileURL(middlewarePath).href); + } catch (e) { + assertIsError(e); + if (e.code !== 'ERR_REQUIRE_ASYNC_MODULE') { + throw e; + } + + middlewareConfiguration = await loadEsmModule<{ default: unknown }>( + pathToFileURL(middlewarePath), + ); + } + + if ('default' in middlewareConfiguration) { + middlewareConfiguration = middlewareConfiguration.default; + } + + return Array.isArray(middlewareConfiguration) + ? middlewareConfiguration + : [middlewareConfiguration]; +} diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts index b3b5e797848f..abe7d4529d84 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts @@ -98,6 +98,7 @@ export function execute( hmr: boolean; allowedHosts: true | string[]; define: { [key: string]: string } | undefined; + middlewareConfig?: string; }, builderName, (options, context, codePlugins) => { diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/schema.json b/packages/angular_devkit/build_angular/src/builders/dev-server/schema.json index 495f244b1722..9d6ff9a89f1d 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/schema.json @@ -103,7 +103,9 @@ "type": "string", "description": "Activate the inspector on host and port in the format of `[[host:]port]`. See the security warning in https://nodejs.org/docs/latest-v22.x/api/cli.html#warning-binding-inspector-to-a-public-ipport-combination-is-insecure regarding the host parameter usage." }, - { "type": "boolean" } + { + "type": "boolean" + } ] }, "forceEsbuild": { @@ -114,22 +116,34 @@ "prebundle": { "description": "Enable and control the Vite-based development server's prebundling capabilities. To enable prebundling, the Angular CLI cache must also be enabled. This option has no effect when using the 'browser' or other Webpack-based builders.", "oneOf": [ - { "type": "boolean" }, + { + "type": "boolean" + }, { "type": "object", "properties": { "exclude": { "description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself.", "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } } }, "additionalProperties": false, - "required": ["exclude"] + "required": [ + "exclude" + ] } ] + }, + "middlewareConfig": { + "type": "string", + "description": "Middleware configuration file." } }, "additionalProperties": false, - "required": ["buildTarget"] -} + "required": [ + "buildTarget" + ] +} \ No newline at end of file diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/tests/options/middleware-config_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/options/middleware-config_spec.ts new file mode 100644 index 000000000000..cf074bd18c57 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/options/middleware-config_spec.ts @@ -0,0 +1,88 @@ +/** + * @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 { createServer } from 'node:http'; +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('option: "middlewareConfig"', () => { + beforeEach(async () => { + setupTarget(harness); + + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', ''); + }); + + it('middleware configuration export single function (CommonJS)', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + middlewareConfig: 'middleware.config.js', + }); + const proxyServer = await createProxyServer(); + try { + await harness.writeFiles({ + 'middleware.config.js': `module.exports = (req, res, next) => { res.end('TEST_MIDDLEWARE'); next();}`, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain('TEST_MIDDLEWARE'); + } finally { + await proxyServer.close(); + } + }); + + it('middleware configuration export an array of multiple functions (CommonJS)', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + middlewareConfig: 'middleware.config.js', + }); + const proxyServer = await createProxyServer(); + try { + await harness.writeFiles({ + 'middleware.config.js': `module.exports = [(req, res, next) => { next();}, (req, res, next) => { res.end('TEST_MIDDLEWARE'); next();}]`, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain('TEST_MIDDLEWARE'); + } finally { + await proxyServer.close(); + } + }); + }); +}); + +/** + * Creates an HTTP Server used for proxy testing that provides a `/test` endpoint + * that returns a 200 response with a body of `TEST_API_RETURN`. All other requests + * will return a 404 response. + */ +async function createProxyServer() { + const proxyServer = createServer((request, response) => { + if (request.url?.endsWith('/test')) { + response.writeHead(200); + response.end('TEST_API_RETURN'); + } else { + response.writeHead(404); + response.end(); + } + }); + + await new Promise((resolve) => proxyServer.listen(0, '127.0.0.1', resolve)); + + return { + address: proxyServer.address() as import('net').AddressInfo, + close: () => new Promise((resolve) => proxyServer.close(() => resolve())), + }; +}