feat(@angular-devkit/build-angular): move Vite-based dev-server for application builder to new build system package

With the `application` builder already within the new `@angular/build` package,
the Vite-based `dev-server` builder is now also contained within this package.
Only the Vite-based aspects of the `dev-server` have been moved and only the
support for the `application` builder. The compatibility builder `browser-esbuild`
is not supported with `@angular/build:dev-server`. The existing `dev-server` builder
found within `@angular-devkit/build-angular` should continue to be used for both the
Webpack-based `browser` builder and the esbuild-based compatibility `browser-esbuild`
builder. To maintain backwards compatibility, the existing `@angular-devkit/build-angular:dev-server`
builder continues to support builders it has previously.

No change to existing applications is required.
This commit is contained in:
Charles Lyding 2024-04-18 14:33:32 -04:00 committed by Charles
parent c4cd1ed0bf
commit 4ffe07aa24
45 changed files with 2762 additions and 79 deletions

View File

@ -8,6 +8,7 @@
import { BuilderContext } from '@angular-devkit/architect';
import { BuilderOutput } from '@angular-devkit/architect';
import type http from 'node:http';
import { OutputFile } from 'esbuild';
import type { Plugin as Plugin_2 } from 'esbuild';
@ -107,6 +108,51 @@ export enum BuildOutputFileType {
Server = 3
}
// @public
export interface DevServerBuilderOptions {
allowedHosts?: string[];
// @deprecated
browserTarget?: string;
buildTarget?: string;
disableHostCheck?: boolean;
forceEsbuild?: boolean;
headers?: {
[key: string]: string;
};
hmr?: boolean;
host?: string;
liveReload?: boolean;
open?: boolean;
poll?: number;
port?: number;
prebundle?: PrebundleUnion;
proxyConfig?: string;
publicHost?: string;
servePath?: string;
ssl?: boolean;
sslCert?: string;
sslKey?: string;
verbose?: boolean;
watch?: boolean;
}
// @public
export interface DevServerBuilderOutput extends BuilderOutput {
// (undocumented)
address?: string;
// (undocumented)
baseUrl: string;
// (undocumented)
port?: number;
}
// @public
export function executeDevServerBuilder(options: DevServerBuilderOptions, context: BuilderContext, extensions?: {
buildPlugins?: Plugin_2[];
middleware?: ((req: http.IncomingMessage, res: http.ServerResponse, next: (err?: unknown) => void) => void)[];
indexHtmlTransformer?: IndexHtmlTransform;
}): AsyncIterable<DevServerBuilderOutput>;
// (No @packageDocumentation comment for this package)
```

View File

@ -12,6 +12,7 @@ import { BuilderContext } from '@angular-devkit/architect';
import { BuilderOutput } from '@angular-devkit/architect';
import type { ConfigOptions } from 'karma';
import { Configuration } from 'webpack';
import { DevServerBuilderOutput } from '@angular/build';
import type http from 'node:http';
import { IndexHtmlTransform } from '@angular/build/private';
import { json } from '@angular-devkit/core';
@ -142,15 +143,7 @@ export interface DevServerBuilderOptions {
watch?: boolean;
}
// @public
export interface DevServerBuilderOutput extends BuilderOutput {
// (undocumented)
address?: string;
// (undocumented)
baseUrl: string;
// (undocumented)
port?: number;
}
export { DevServerBuilderOutput }
// @public
export function executeBrowserBuilder(options: BrowserBuilderOptions, context: BuilderContext, transforms?: {

View File

@ -12,6 +12,11 @@ ts_json_schema(
src = "src/builders/application/schema.json",
)
ts_json_schema(
name = "dev-server_schema",
src = "src/builders/dev-server/schema.json",
)
ts_library(
name = "build",
package_name = "@angular/build",
@ -28,6 +33,7 @@ ts_library(
],
) + [
"//packages/angular/build:src/builders/application/schema.ts",
"//packages/angular/build:src/builders/dev-server/schema.ts",
],
data = glob(
include = [
@ -57,11 +63,13 @@ ts_library(
"@npm//@babel/helper-split-export-declaration",
"@npm//@types/babel__core",
"@npm//@types/browserslist",
"@npm//@types/inquirer",
"@npm//@types/less",
"@npm//@types/node",
"@npm//@types/picomatch",
"@npm//@types/semver",
"@npm//@types/watchpack",
"@npm//@vitejs/plugin-basic-ssl",
"@npm//ansi-colors",
"@npm//autoprefixer",
"@npm//browserslist",
@ -69,6 +77,7 @@ ts_library(
"@npm//esbuild",
"@npm//fast-glob",
"@npm//https-proxy-agent",
"@npm//inquirer",
"@npm//less",
"@npm//magic-string",
"@npm//mrmime",
@ -81,6 +90,7 @@ ts_library(
"@npm//tslib",
"@npm//typescript",
"@npm//undici",
"@npm//vite",
"@npm//watchpack",
],
)
@ -132,6 +142,11 @@ ts_library(
"//packages/angular_devkit/core",
"//packages/angular_devkit/core/node",
# dev server only test deps
"@npm//@types/http-proxy",
"@npm//http-proxy",
"@npm//puppeteer",
# Base dependencies for the application in hello-world-app.
"@npm//@angular/common",
"@npm//@angular/compiler",

View File

@ -1,9 +1,14 @@
{
"builders": {
"application": {
"implementation": "./src/builders/application",
"implementation": "./src/builders/application/index",
"schema": "./src/builders/application/schema.json",
"description": "Build an application."
},
"dev-server": {
"implementation": "./src/builders/dev-server/index",
"schema": "./src/builders/dev-server/schema.json",
"description": "Execute a development server for an application."
}
}
}

View File

@ -23,11 +23,13 @@
"@babel/core": "7.24.4",
"@babel/helper-annotate-as-pure": "7.22.5",
"@babel/helper-split-export-declaration": "7.22.6",
"@vitejs/plugin-basic-ssl": "1.1.0",
"browserslist": "^4.23.0",
"critters": "0.0.22",
"esbuild": "0.20.2",
"fast-glob": "3.3.2",
"https-proxy-agent": "7.0.4",
"inquirer": "9.2.19",
"less": "4.2.0",
"magic-string": "0.30.10",
"mrmime": "2.0.0",
@ -38,6 +40,7 @@
"sass": "1.75.0",
"semver": "7.6.0",
"undici": "6.13.0",
"vite": "5.2.10",
"watchpack": "2.4.1"
},
"peerDependencies": {

View File

@ -0,0 +1,117 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import type { BuilderContext } from '@angular-devkit/architect';
import type { Plugin } from 'esbuild';
import type http from 'node:http';
import { checkPort } from '../../utils/check-port';
import {
type IndexHtmlTransform,
buildApplicationInternal,
purgeStaleBuildCache,
} from './internal';
import { normalizeOptions } from './options';
import type { DevServerBuilderOutput } from './output';
import type { Schema as DevServerBuilderOptions } from './schema';
import { serveWithVite } from './vite-server';
/**
* A Builder that executes a development server based on the provided browser target option.
*
* Usage of the `transforms` and/or `extensions` parameters is NOT supported and may cause
* unexpected build output or build failures.
*
* @param options Dev Server options.
* @param context The build context.
* @param extensions An optional object containing an array of build plugins (esbuild-based)
* and/or HTTP request middleware.
*
* @experimental Direct usage of this function is considered experimental.
*/
export async function* execute(
options: DevServerBuilderOptions,
context: BuilderContext,
extensions?: {
buildPlugins?: Plugin[];
middleware?: ((
req: http.IncomingMessage,
res: http.ServerResponse,
next: (err?: unknown) => void,
) => void)[];
indexHtmlTransformer?: IndexHtmlTransform;
},
): AsyncIterable<DevServerBuilderOutput> {
// Determine project name from builder context target
const projectName = context.target?.project;
if (!projectName) {
context.logger.error(`The "dev-server" builder requires a target to be specified.`);
return;
}
const { builderName, normalizedOptions } = await initialize(options, projectName, context);
// Warn if the initial options provided by the user enable prebundling but caching is disabled
if (options.prebundle && !normalizedOptions.cacheOptions.enabled) {
context.logger.warn(
`Prebundling has been configured but will not be used because caching has been disabled.`,
);
}
yield* serveWithVite(
normalizedOptions,
builderName,
(options, context, plugins) =>
buildApplicationInternal(options, context, { write: false }, { codePlugins: plugins }),
context,
{ indexHtml: extensions?.indexHtmlTransformer },
extensions,
);
}
async function initialize(
initialOptions: DevServerBuilderOptions,
projectName: string,
context: BuilderContext,
) {
// Purge old build disk cache.
await purgeStaleBuildCache(context);
const normalizedOptions = await normalizeOptions(context, projectName, initialOptions);
const builderName = await context.getBuilderNameForTarget(normalizedOptions.buildTarget);
if (
!normalizedOptions.disableHostCheck &&
!/^127\.\d+\.\d+\.\d+/g.test(normalizedOptions.host) &&
normalizedOptions.host !== 'localhost'
) {
context.logger.warn(`
Warning: This is a simple server for use in testing or debugging Angular applications
locally. It hasn't been reviewed for security issues.
Binding this server to an open connection can result in compromising your application or
computer. Using a different host than the one passed to the "--host" flag might result in
websocket connection issues. You might need to use "--disable-host-check" if that's the
case.
`);
}
if (normalizedOptions.disableHostCheck) {
context.logger.warn(
'Warning: Running a server with --disable-host-check is a security risk. ' +
'See https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a for more information.',
);
}
normalizedOptions.port = await checkPort(normalizedOptions.port, normalizedOptions.host);
return {
builderName,
normalizedOptions,
};
}

View File

@ -0,0 +1,18 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { createBuilder } from '@angular-devkit/architect';
import { execute } from './builder';
import { DevServerBuilderOutput } from './output';
import { Schema as DevServerBuilderOptions } from './schema';
export { DevServerBuilderOptions, DevServerBuilderOutput, execute as executeDevServerBuilder };
export default createBuilder<DevServerBuilderOptions, DevServerBuilderOutput>(execute);
// Temporary export to support specs
export { execute as executeDevServer };

View File

@ -0,0 +1,20 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
export { BuildOutputFile, BuildOutputFileType } from '@angular/build';
export { createRxjsEsmResolutionPlugin } from '../../tools/esbuild/rxjs-esm-resolution-plugin';
export { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer';
export { getFeatureSupport, isZonelessApp } from '../../tools/esbuild/utils';
export { renderPage } from '../../utils/server-rendering/render-page';
export { type IndexHtmlTransform } from '../../utils/index-file/index-html-generator';
export { purgeStaleBuildCache } from '../../utils/purge-cache';
export { getSupportedBrowsers } from '../../utils/supported-browsers';
export { transformSupportedBrowsersToTargets } from '../../tools/esbuild/utils';
export { buildApplicationInternal } from '../../builders/application';
export { ApplicationBuilderInternalOptions } from '../../builders/application/options';
export { ExternalResultMetadata } from '../../tools/esbuild/bundler-execution-result';

View File

@ -0,0 +1,91 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { BuilderContext, targetFromTargetString } from '@angular-devkit/architect';
import path from 'node:path';
import { normalizeCacheOptions } from '../../utils/normalize-cache';
import { Schema as DevServerOptions } from './schema';
export type NormalizedDevServerOptions = Awaited<ReturnType<typeof normalizeOptions>>;
/**
* Normalize the user provided options by creating full paths for all path based options
* and converting multi-form options into a single form that can be directly used
* by the build process.
*
* @param context The context for current builder execution.
* @param projectName The name of the project for the current execution.
* @param options An object containing the options to use for the build.
* @returns An object containing normalized options required to perform the build.
*/
export async function normalizeOptions(
context: BuilderContext,
projectName: string,
options: DevServerOptions,
) {
const workspaceRoot = context.workspaceRoot;
const projectMetadata = await context.getProjectMetadata(projectName);
const projectRoot = path.join(workspaceRoot, (projectMetadata.root as string | undefined) ?? '');
const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot);
// Target specifier defaults to the current project's build target using a development configuration
const buildTargetSpecifier = options.buildTarget ?? options.browserTarget ?? `::development`;
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');
// Initial options to keep
const {
host,
port,
poll,
open,
verbose,
watch,
allowedHosts,
disableHostCheck,
liveReload,
hmr,
headers,
proxyConfig,
servePath,
publicHost,
ssl,
sslCert,
sslKey,
forceEsbuild,
prebundle,
} = options;
// Return all the normalized options
return {
buildTarget,
host: host ?? 'localhost',
port: port ?? 4200,
poll,
open,
verbose,
watch,
liveReload,
hmr,
headers,
workspaceRoot,
projectRoot,
cacheOptions,
allowedHosts,
disableHostCheck,
proxyConfig,
servePath,
publicHost,
ssl,
sslCert,
sslKey,
forceEsbuild,
// Prebundling defaults to true but requires caching to function
prebundle: cacheOptions.enabled && (prebundle ?? true),
};
}

View File

@ -0,0 +1,131 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Dev Server Target",
"description": "Dev Server target options for Build Facade.",
"type": "object",
"properties": {
"browserTarget": {
"type": "string",
"description": "A browser builder target to serve in the format of `project:target[:configuration]`. You can also pass in more than one configuration name as a comma-separated list. Example: `project:target:production,staging`.",
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$",
"x-deprecated": "Use 'buildTarget' instead."
},
"buildTarget": {
"type": "string",
"description": "A build builder target to serve in the format of `project:target[:configuration]`. You can also pass in more than one configuration name as a comma-separated list. Example: `project:target:production,staging`.",
"pattern": "^[^:\\s]*:[^:\\s]*(:[^\\s]+)?$"
},
"port": {
"type": "number",
"description": "Port to listen on.",
"default": 4200
},
"host": {
"type": "string",
"description": "Host to listen on.",
"default": "localhost"
},
"proxyConfig": {
"type": "string",
"description": "Proxy configuration file. For more information, see https://angular.io/guide/build#proxying-to-a-backend-server."
},
"ssl": {
"type": "boolean",
"description": "Serve using HTTPS.",
"default": false
},
"sslKey": {
"type": "string",
"description": "SSL key to use for serving HTTPS."
},
"sslCert": {
"type": "string",
"description": "SSL certificate to use for serving HTTPS."
},
"headers": {
"type": "object",
"description": "Custom HTTP headers to be added to all responses.",
"propertyNames": {
"pattern": "^[-_A-Za-z0-9]+$"
},
"additionalProperties": {
"type": "string"
}
},
"open": {
"type": "boolean",
"description": "Opens the url in default browser.",
"default": false,
"alias": "o"
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging."
},
"liveReload": {
"type": "boolean",
"description": "Whether to reload the page on change, using live-reload.",
"default": true
},
"publicHost": {
"type": "string",
"description": "The URL that the browser client (or live-reload client, if enabled) should use to connect to the development server. Use for a complex dev server setup, such as one with reverse proxies. This option has no effect when using the 'application' or other esbuild-based builders."
},
"allowedHosts": {
"type": "array",
"description": "List of hosts that are allowed to access the dev server. This option has no effect when using the 'application' or other esbuild-based builders.",
"default": [],
"items": {
"type": "string"
}
},
"servePath": {
"type": "string",
"description": "The pathname where the application will be served."
},
"disableHostCheck": {
"type": "boolean",
"description": "Don't verify connected clients are part of allowed hosts. This option has no effect when using the 'application' or other esbuild-based builders.",
"default": false
},
"hmr": {
"type": "boolean",
"description": "Enable hot module replacement.",
"default": false
},
"watch": {
"type": "boolean",
"description": "Rebuild on change.",
"default": true
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period in milliseconds."
},
"forceEsbuild": {
"type": "boolean",
"description": "Force the development server to use the 'browser-esbuild' builder when building. This is a developer preview option for the esbuild-based build system.",
"default": false
},
"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": "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" }
}
},
"additionalProperties": false,
"required": ["exclude"]
}
]
}
},
"additionalProperties": false,
"anyOf": [{ "required": ["buildTarget"] }, { "required": ["browserTarget"] }]
}

View File

@ -0,0 +1,125 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { 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, isVite) => {
const javascriptFileContent =
"import {foo} from 'unresolved'; /* a comment */const foo = `bar`;\n\n\n";
describe('Behavior: "browser builder assets"', () => {
it('serves a project JavaScript asset unmodified', async () => {
await harness.writeFile('src/extra.js', javascriptFileContent);
setupTarget(harness, {
assets: ['src/extra.js'],
optimization: {
scripts: true,
},
});
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, 'extra.js');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain(javascriptFileContent);
});
it('serves a project TypeScript asset unmodified', async () => {
await harness.writeFile('src/extra.ts', javascriptFileContent);
setupTarget(harness, {
assets: ['src/extra.ts'],
});
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, 'extra.ts');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain(javascriptFileContent);
});
it('should return 404 for non existing assets', async () => {
setupTarget(harness, {
assets: [],
optimization: {
scripts: true,
},
});
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, 'does-not-exist.js');
expect(result?.success).toBeTrue();
expect(await response?.status).toBe(404);
});
it(`should return the asset that matches 'index.html' when path has a trailing '/'`, async () => {
await harness.writeFile(
'src/login/index.html',
'<html><body><h1>Login page</h1></body><html>',
);
setupTarget(harness, {
assets: ['src/login'],
optimization: {
scripts: true,
},
});
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, 'login/');
expect(result?.success).toBeTrue();
expect(await response?.status).toBe(200);
expect(await response?.text()).toContain('<h1>Login page</h1>');
});
(isVite ? it : xit)(
`should return the asset that matches '.html' when path has no trailing '/'`,
async () => {
await harness.writeFile(
'src/login/new.html',
'<html><body><h1>Login page</h1></body><html>',
);
setupTarget(harness, {
assets: ['src/login'],
optimization: {
scripts: true,
},
});
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, 'login/new');
expect(result?.success).toBeTrue();
expect(await response?.status).toBe(200);
expect(await response?.text()).toContain('<h1>Login page</h1>');
},
);
});
});

View File

@ -0,0 +1,49 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { 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: "buildTarget baseHref"', () => {
beforeEach(async () => {
setupTarget(harness, {
baseHref: '/test/',
});
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', 'console.log("foo");');
});
it('uses the baseHref defined in the "buildTarget" options as the serve path', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, '/test/main.js');
expect(result?.success).toBeTrue();
const baseUrl = new URL(`${result?.baseUrl}/`);
expect(baseUrl.pathname).toBe('/test/');
expect(await response?.text()).toContain('console.log');
});
it('serves the application from baseHref location without trailing slash', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, '/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('<script src="main.js" type="module">');
});
});
});

View File

@ -0,0 +1,39 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { BudgetType } from '../../../../utils/bundle-calculator';
import { executeDevServer } from '../../index';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
// TODO(fix-vite): currently this is broken in vite.
(isViteRun ? xdescribe : describe)('Behavior: "browser builder budgets"', () => {
beforeEach(() => {
setupTarget(harness, {
// Add a budget error for any file over 100 bytes
budgets: [{ type: BudgetType.All, maximumError: '100b' }],
optimization: true,
});
});
it('should ignore budgets defined in the "buildTarget" options', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result } = await harness.executeOnce();
expect(result?.success).toBe(true);
});
});
},
);

View File

@ -0,0 +1,46 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { 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: "browser builder inline critical css"', () => {
beforeEach(async () => {
setupTarget(harness, {
optimization: {
styles: {
minify: true,
inlineCritical: true,
},
},
styles: ['src/styles.css'],
});
await harness.writeFiles({
'src/styles.css': 'body { color: #000 }',
});
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', '');
});
it('inlines critical css when enabled in the "browserTarget" options', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, '/');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('body{color:#000}');
});
});
});

View File

@ -0,0 +1,85 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/* eslint-disable max-len */
import { concatMap, count, take, timeout } from 'rxjs';
import { URL } from 'url';
import { executeDevServer } from '../../index';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, BUILD_TIMEOUT, DEV_SERVER_BUILDER_INFO } from '../setup';
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
// TODO(fix-vite): currently this is broken in vite.
(isViteRun ? xdescribe : describe)(
'Behavior: "i18n $localize calls are replaced during watching"',
() => {
beforeEach(() => {
harness.useProject('test', {
root: '.',
sourceRoot: 'src',
cli: {
cache: {
enabled: false,
},
},
i18n: {
sourceLocale: {
'code': 'fr',
},
},
});
setupTarget(harness, { localize: ['fr'] });
});
it('$localize are replaced in watch', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
watch: true,
});
await harness.writeFile(
'src/app/app.component.html',
`
<p id="hello" i18n="An introduction header for this sample">Hello {{ title }}! </p>
`,
);
const buildCount = await harness
.execute()
.pipe(
timeout(BUILD_TIMEOUT * 2),
concatMap(async ({ result }, index) => {
expect(result?.success).toBe(true);
const response = await fetch(new URL('main.js', `${result?.baseUrl}`));
expect(await response?.text()).not.toContain('$localize`:');
switch (index) {
case 0: {
await harness.modifyFile('src/app/app.component.html', (content) =>
content.replace('introduction', 'intro'),
);
break;
}
}
}),
take(2),
count(),
)
.toPromise();
expect(buildCount).toBe(2);
});
},
);
},
);

View File

@ -0,0 +1,109 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/* eslint-disable max-len */
import { concatMap, count, take, timeout } from 'rxjs';
import { URL } from 'url';
import { executeDevServer } from '../../index';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, BUILD_TIMEOUT, DEV_SERVER_BUILDER_INFO } from '../setup';
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
// TODO(fix-vite): currently this is broken in vite.
(isViteRun ? xdescribe : describe)('Behavior: "i18n translation file watching"', () => {
beforeEach(() => {
harness.useProject('test', {
root: '.',
sourceRoot: 'src',
cli: {
cache: {
enabled: false,
},
},
i18n: {
locales: {
'fr': 'src/locales/messages.fr.xlf',
},
},
});
setupTarget(harness, { localize: ['fr'] });
});
it('watches i18n translation files by default', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
watch: true,
});
await harness.writeFile(
'src/app/app.component.html',
`
<p id="hello" i18n="An introduction header for this sample">Hello {{ title }}! </p>
`,
);
await harness.writeFile('src/locales/messages.fr.xlf', TRANSLATION_FILE_CONTENT);
const buildCount = await harness
.execute()
.pipe(
timeout(BUILD_TIMEOUT),
concatMap(async ({ result }, index) => {
expect(result?.success).toBe(true);
const mainUrl = new URL('main.js', `${result?.baseUrl}`);
switch (index) {
case 0: {
const response = await fetch(mainUrl);
expect(await response?.text()).toContain('Bonjour');
await harness.modifyFile('src/locales/messages.fr.xlf', (content) =>
content.replace('Bonjour', 'Salut'),
);
break;
}
case 1: {
const response = await fetch(mainUrl);
expect(await response?.text()).toContain('Salut');
break;
}
}
}),
take(2),
count(),
)
.toPromise();
expect(buildCount).toBe(2);
});
});
},
);
const TRANSLATION_FILE_CONTENT = `
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file target-language="en-US" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="4286451273117902052" datatype="html">
<target>Bonjour <x id="INTERPOLATION" equiv-text="{{ title }}"/>! </target>
<context-group purpose="location">
<context context-type="targetfile">src/app/app.component.html</context>
<context context-type="linenumber">2,3</context>
</context-group>
<note priority="1" from="description">An introduction header for this sample</note>
</trans-unit>
</body>
</file>
</xliff>
`;

View File

@ -0,0 +1,321 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/* eslint-disable import/no-extraneous-dependencies */
import { tags } from '@angular-devkit/core';
import { createServer } from 'http';
import { createProxyServer } from 'http-proxy';
import { AddressInfo } from 'net';
import puppeteer, { Browser, Page } from 'puppeteer';
import { count, debounceTime, finalize, switchMap, take, timeout } from 'rxjs';
import { executeDevServer } from '../../index';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, BUILD_TIMEOUT, DEV_SERVER_BUILDER_INFO } from '../setup';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const document: any;
interface ProxyInstance {
server: typeof createProxyServer extends () => infer R ? R : never;
url: string;
}
function findFreePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const server = createServer();
server.once('listening', () => {
const port = (server.address() as AddressInfo).port;
server.close((e) => (e ? reject(e) : resolve(port)));
});
server.once('error', (e) => server.close(() => reject(e)));
server.listen();
});
}
async function createProxy(target: string, secure: boolean, ws = true): Promise<ProxyInstance> {
const proxyPort = await findFreePort();
const server = createProxyServer({
ws,
target,
secure,
ssl: secure && {
key: tags.stripIndents`
-----BEGIN RSA PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEBRUsUz4rdcMt
CQGLvG3SzUinsmgdgOyTNQNA0eOMyRSrmS8L+F/kSLUnqqu4mzdeqDzo2Xj553jK
dRqMCRFGJuGnQ/VIbW2A+ywgrqILuDyF5i4PL1aQW4yJ7TnXfONKfpswQArlN6DF
gBYJtoJlf8XD1sOeJpsv/O46/ix/wngQ+GwQQ2cfqxQT0fE9SBCY23VNt3SPUJ3k
9etJMvJ9U9GHSb1CFdNQe7Gyx7xdKf1TazB27ElNZEg2aF99if47uRskYjvvFivy
7nxGx/ccIwjwNMpk29AsKG++0sn1yTK7tD5Px6aCSVK0BKbdXZS2euJor8hASGBJ
3GpVGJvdAgMBAAECggEAapYo8TVCdPdP7ckb4hPP0/R0MVu9aW2VNmZ5ImH+zar5
ZmWhQ20HF2bBupP/VB5yeTIaDLNUKO9Iqy4KBWNY1UCHKyC023FFPgFV+V98FctU
faqwGOmwtEZToRwxe48ZOISndhEc247oCPyg/x8SwIY9z0OUkwaDFBEAqWtUXxM3
/SPpCT5ilLgxnRgVB8Fj5Z0q7ThnxNVOmVC1OSIakEj46PzmMXn1pCKLOCUmAAOQ
BnrOZuty2b8b2M/GHsktLZwojQQJmArnIBymTXQTVhaGgKSyOv1qvHLp9L1OJf0/
Xm+/TqT6ztzhzlftcObdfQZZ5JuoEwlvyrsGFlA3MQKBgQDiQC3KYMG8ViJkWrv6
XNAFEoAjVEKrtirGWJ66YfQ9KSJ7Zttrd1Y1V1OLtq3z4YMH39wdQ8rOD+yR8mWV
6Tnsxma6yJXAH8uan8iVbxjIZKF1hnvNCxUoxYmWOmTLcEQMzmxvTzAiR+s6R6Uj
9LgGqppt30nM4wnOhOJU6UxqbwKBgQDdy03KidbPZuycJSy1C9AIt0jlrxDsYm+U
fZrB6mHEZcgoZS5GbLKinQCdGcgERa05BXvJmNbfZtT5a37YEnbjsTImIhDiBP5P
nW36/9a3Vg1svd1KP2206/Bh3gfZbgTsQg4YogXgjf0Uzuvw18btgTtLVpVyeuqz
TU3eeF30cwKBgQCN6lvOmapsDEs+T3uhqx4AUH53qp63PmjOSUAnANJGmsq6ROZV
HmHAy6nn9Qpf85BRHCXhZWiMoIhvc3As/EINNtWxS6hC/q6jqp4SvcD50cVFBroY
/16iWGXZCX+37A+DSOfTWgSDPEFcKRx41UOpStHbITgVgEPieo/NWxlHmQKBgQDX
JOLs2RB6V0ilnpnjdPXzvncD9fHgmwvJap24BPeZX3HtXViqD76oZsu1mNCg9EW3
zk3pnEyyoDlvSIreZerVq4kN3HWsCVP3Pqr0kz9g0CRtmy8RWr28hjHDfXD3xPUZ
iGnMEz7IOHOKv722/liFAprV1cNaLUmFbDNg3jmlaQKBgQDG5WwngPhOHmjTnSml
amfEz9a4yEhQqpqgVNW5wwoXOf6DbjL2m/maJh01giThj7inMcbpkZlIclxD0Eu6
Lof+ctCeqSAJvaVPmd+nv8Yp26zsF1yM8ax9xXjrIvv9fSbycNveGTDCsNNTiYoW
QyvMqmN1kGy20SZbQDD/fLfqBQ==
-----END RSA PRIVATE KEY-----
`,
cert: tags.stripIndents`
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJALz8gD/gAt0OMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTgxMDIzMTgyMTQ5WhcNMTkxMDIzMTgyMTQ5WjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAxAUVLFM+K3XDLQkBi7xt0s1Ip7JoHYDskzUDQNHjjMkUq5kvC/hf5Ei1
J6qruJs3Xqg86Nl4+ed4ynUajAkRRibhp0P1SG1tgPssIK6iC7g8heYuDy9WkFuM
ie0513zjSn6bMEAK5TegxYAWCbaCZX/Fw9bDniabL/zuOv4sf8J4EPhsEENnH6sU
E9HxPUgQmNt1Tbd0j1Cd5PXrSTLyfVPRh0m9QhXTUHuxsse8XSn9U2swduxJTWRI
NmhffYn+O7kbJGI77xYr8u58Rsf3HCMI8DTKZNvQLChvvtLJ9ckyu7Q+T8emgklS
tASm3V2UtnriaK/IQEhgSdxqVRib3QIDAQABo1AwTjAdBgNVHQ4EFgQUDZBhVKdb
3BRhLIhuuE522Vsul0IwHwYDVR0jBBgwFoAUDZBhVKdb3BRhLIhuuE522Vsul0Iw
DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABh9WWZwWLgb9/DcTxL72
6pI96t4jiF79Q+pPefkaIIi0mE6yodWrTAsBQu9I6bNRaEcCSoiXkP2bqskD/UGg
LwUFgSrDOAA3UjdHw3QU5g2NocduG7mcFwA40TB98sOsxsUyYlzSyWzoiQWwPYwb
hek1djuWkqPXsTjlj54PTPN/SjTFmo4p5Ip6nbRf2nOREl7v0rJpGbJvXiCMYyd+
Zv+j4mRjCGo8ysMR2HjCUGkYReLAgKyyz3M7i8vevJhKslyOmy6Txn4F0nPVumaU
DDIy4xXPW1STWfsmSYJfYW3wa0wk+pJQ3j2cTzkPQQ8gwpvM3U9DJl43uwb37v6I
7Q==
-----END CERTIFICATE-----
`,
},
}).listen(proxyPort);
return {
server,
url: `${secure ? 'https' : 'http'}://localhost:${proxyPort}`,
};
}
async function goToPageAndWaitForWS(page: Page, url: string): Promise<void> {
const baseUrl = url.replace(/^http/, 'ws');
const socksRequest =
baseUrl[baseUrl.length - 1] === '/' ? `${baseUrl}ng-cli-ws` : `${baseUrl}/ng-cli-ws`;
// Create a Chrome dev tools session so that we can capturing websocket request.
// https://github.com/puppeteer/puppeteer/issues/2974
// We do this, to ensure that we make the right request with the expected host, port etc...
const client = await page.target().createCDPSession();
await client.send('Network.enable');
await client.send('Page.enable');
await Promise.all([
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`A Websocket connected to ${socksRequest} was not established.`)),
2000,
);
client.on('Network.webSocketCreated', ({ url }) => {
if (url.startsWith(socksRequest)) {
clearTimeout(timeout);
resolve();
}
});
}),
page.goto(url),
]);
await client.detach();
}
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
// TODO(fix-vite): currently this is broken in vite.
(isViteRun ? xdescribe : describe)(
'Behavior: "Dev-server builder live-reload with proxies"',
() => {
let browser: Browser;
let page: Page;
const SERVE_OPTIONS = Object.freeze({
...BASE_OPTIONS,
hmr: false,
watch: true,
liveReload: true,
});
beforeAll(async () => {
browser = await puppeteer.launch({
// MacOSX users need to set the local binary manually because Chrome has lib files with
// spaces in them which Bazel does not support in runfiles
// See: https://github.com/angular/angular-cli/pull/17624
// eslint-disable-next-line max-len
// executablePath: '/Users/<USERNAME>/git/angular-cli/node_modules/puppeteer/.local-chromium/mac-818858/chrome-mac/Chromium.app/Contents/MacOS/Chromium',
ignoreHTTPSErrors: true,
args: ['--no-sandbox', '--disable-gpu'],
});
});
afterAll(async () => {
await browser.close();
});
beforeEach(async () => {
setupTarget(harness, {
polyfills: ['src/polyfills.ts'],
});
page = await browser.newPage();
});
afterEach(async () => {
await page.close();
});
it('works without proxy', async () => {
harness.useTarget('serve', {
...SERVE_OPTIONS,
});
await harness.writeFile('src/app/app.component.html', '<p>{{ title }}</p>');
const buildCount = await harness
.execute()
.pipe(
debounceTime(1000),
timeout(BUILD_TIMEOUT * 2),
switchMap(async ({ result }, index) => {
expect(result?.success).toBeTrue();
if (typeof result?.baseUrl !== 'string') {
throw new Error('Expected "baseUrl" to be a string.');
}
switch (index) {
case 0:
await goToPageAndWaitForWS(page, result.baseUrl);
await harness.modifyFile('src/app/app.component.ts', (content) =>
content.replace(`'app'`, `'app-live-reload'`),
);
break;
case 1:
const innerText = await page.evaluate(
() => document.querySelector('p').innerText,
);
expect(innerText).toBe('app-live-reload');
break;
}
}),
take(2),
count(),
)
.toPromise();
expect(buildCount).toBe(2);
});
it('works without http -> http proxy', async () => {
harness.useTarget('serve', {
...SERVE_OPTIONS,
});
await harness.writeFile('src/app/app.component.html', '<p>{{ title }}</p>');
let proxy: ProxyInstance | undefined;
const buildCount = await harness
.execute()
.pipe(
debounceTime(1000),
timeout(BUILD_TIMEOUT * 2),
switchMap(async ({ result }, index) => {
expect(result?.success).toBeTrue();
if (typeof result?.baseUrl !== 'string') {
throw new Error('Expected "baseUrl" to be a string.');
}
switch (index) {
case 0:
proxy = await createProxy(result.baseUrl, false);
await goToPageAndWaitForWS(page, proxy.url);
await harness.modifyFile('src/app/app.component.ts', (content) =>
content.replace(`'app'`, `'app-live-reload'`),
);
break;
case 1:
const innerText = await page.evaluate(
() => document.querySelector('p').innerText,
);
expect(innerText).toBe('app-live-reload');
break;
}
}),
take(2),
count(),
finalize(() => {
proxy?.server.close();
}),
)
.toPromise();
expect(buildCount).toBe(2);
});
it('works without https -> http proxy', async () => {
harness.useTarget('serve', {
...SERVE_OPTIONS,
});
await harness.writeFile('src/app/app.component.html', '<p>{{ title }}</p>');
let proxy: ProxyInstance | undefined;
const buildCount = await harness
.execute()
.pipe(
debounceTime(1000),
timeout(BUILD_TIMEOUT * 2),
switchMap(async ({ result }, index) => {
expect(result?.success).toBeTrue();
if (typeof result?.baseUrl !== 'string') {
throw new Error('Expected "baseUrl" to be a string.');
}
switch (index) {
case 0:
proxy = await createProxy(result.baseUrl, true);
await goToPageAndWaitForWS(page, proxy.url);
await harness.modifyFile('src/app/app.component.ts', (content) =>
content.replace(`'app'`, `'app-live-reload'`),
);
break;
case 1:
const innerText = await page.evaluate(
() => document.querySelector('p').innerText,
);
expect(innerText).toBe('app-live-reload');
break;
}
}),
take(2),
count(),
finalize(() => {
proxy?.server.close();
}),
)
.toPromise();
expect(buildCount).toBe(2);
});
},
);
},
);

View File

@ -0,0 +1,224 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { concatMap, count, take, timeout } from 'rxjs';
import { executeDevServer } from '../../index';
import { executeOnceAndFetch } from '../execute-fetch';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, BUILD_TIMEOUT, DEV_SERVER_BUILDER_INFO } from '../setup';
const manifest = {
index: '/index.html',
assetGroups: [
{
name: 'app',
installMode: 'prefetch',
resources: {
files: ['/favicon.ico', '/index.html'],
},
},
{
name: 'assets',
installMode: 'lazy',
updateMode: 'prefetch',
resources: {
files: [
'/media/**',
'/assets/**',
'/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)',
],
},
},
],
};
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
describe('Behavior: "dev-server builder serves service worker"', () => {
beforeEach(async () => {
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', '');
await harness.writeFile('src/polyfills.ts', '');
harness.useProject('test', {
root: '.',
sourceRoot: 'src',
cli: {
cache: {
enabled: false,
},
},
i18n: {
sourceLocale: {
'code': 'fr',
},
},
});
});
it('works with service worker', async () => {
setupTarget(harness, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serviceWorker: (isViteRun ? 'ngsw-config.json' : true) as any,
assets: ['src/favicon.ico', 'src/assets'],
styles: ['src/styles.css'],
});
await harness.writeFiles({
'ngsw-config.json': JSON.stringify(manifest),
'src/assets/folder-asset.txt': 'folder-asset.txt',
'src/styles.css': `body { background: url(./spectrum.png); }`,
});
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, '/ngsw.json');
expect(result?.success).toBeTrue();
expect(await response?.json()).toEqual(
jasmine.objectContaining({
configVersion: 1,
index: '/index.html',
navigationUrls: [
{ positive: true, regex: '^\\/.*$' },
{ positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$' },
{ positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$' },
{ positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$' },
],
assetGroups: [
{
name: 'app',
installMode: 'prefetch',
updateMode: 'prefetch',
urls: ['/favicon.ico', '/index.html'],
cacheQueryOptions: {
ignoreVary: true,
},
patterns: [],
},
{
name: 'assets',
installMode: 'lazy',
updateMode: 'prefetch',
urls: ['/assets/folder-asset.txt', '/media/spectrum.png'],
cacheQueryOptions: {
ignoreVary: true,
},
patterns: [],
},
],
dataGroups: [],
hashTable: {
'/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01',
'/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4',
'/index.html': isViteRun
? 'e5b73e6798d2782bf59dd5272d254d5bde364695'
: '9d232e3e13b4605d197037224a2a6303dd337480',
'/media/spectrum.png': '8d048ece46c0f3af4b598a95fd8e4709b631c3c0',
},
}),
);
});
it('works with localize', async () => {
setupTarget(harness, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serviceWorker: (isViteRun ? 'ngsw-config.json' : true) as any,
assets: ['src/favicon.ico', 'src/assets'],
styles: ['src/styles.css'],
localize: ['fr'],
});
await harness.writeFiles({
'ngsw-config.json': JSON.stringify(manifest),
'src/assets/folder-asset.txt': 'folder-asset.txt',
'src/styles.css': `body { background: url(./spectrum.png); }`,
});
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, '/ngsw.json');
expect(result?.success).toBeTrue();
expect(await response?.json()).toBeDefined();
});
// TODO(fix-vite): currently this is broken in vite due to watcher never terminates.
(isViteRun ? xit : it)('works in watch mode', async () => {
setupTarget(harness, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serviceWorker: (isViteRun ? 'ngsw-config.json' : true) as any,
assets: ['src/favicon.ico', 'src/assets'],
styles: ['src/styles.css'],
});
await harness.writeFiles({
'ngsw-config.json': JSON.stringify(manifest),
'src/assets/folder-asset.txt': 'folder-asset.txt',
'src/styles.css': `body { background: url(./spectrum.png); }`,
});
harness.useTarget('serve', {
...BASE_OPTIONS,
watch: true,
});
const buildCount = await harness
.execute()
.pipe(
timeout(BUILD_TIMEOUT),
concatMap(async ({ result }, index) => {
expect(result?.success).toBeTrue();
const response = await fetch(new URL('ngsw.json', `${result?.baseUrl}`));
const { hashTable } = (await response.json()) as { hashTable: object };
const hashTableEntries = Object.keys(hashTable);
switch (index) {
case 0:
expect(hashTableEntries).toEqual([
'/assets/folder-asset.txt',
'/favicon.ico',
'/index.html',
'/media/spectrum.png',
]);
await harness.writeFile(
'src/assets/folder-new-asset.txt',
harness.readFile('src/assets/folder-asset.txt'),
);
break;
case 1:
expect(hashTableEntries).toEqual([
'/assets/folder-asset.txt',
'/assets/folder-new-asset.txt',
'/favicon.ico',
'/index.html',
'/media/spectrum.png',
]);
break;
}
}),
take(2),
count(),
)
.toPromise();
expect(buildCount).toBe(2);
});
});
},
);

View File

@ -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.io/license
*/
import { lastValueFrom, mergeMap, take, timeout } from 'rxjs';
import { URL } from 'url';
import {
BuilderHarness,
BuilderHarnessExecutionOptions,
BuilderHarnessExecutionResult,
} from './setup';
export async function executeOnceAndFetch<T>(
harness: BuilderHarness<T>,
url: string,
options?: Partial<BuilderHarnessExecutionOptions> & { request?: RequestInit },
): Promise<BuilderHarnessExecutionResult & { response?: Response; content?: string }> {
return lastValueFrom(
harness.execute().pipe(
timeout(30000),
mergeMap(async (executionResult) => {
let response = undefined;
let content = undefined;
if (executionResult.result?.success) {
let baseUrl = `${executionResult.result.baseUrl}`;
baseUrl = baseUrl[baseUrl.length - 1] === '/' ? baseUrl : `${baseUrl}/`;
const resolvedUrl = new URL(url, baseUrl);
const originalResponse = await fetch(resolvedUrl, options?.request);
response = originalResponse.clone();
// Ensure all data is available before stopping server
content = await originalResponse.text();
}
return { ...executionResult, response, content };
}),
take(1),
),
);
}

View File

@ -0,0 +1,41 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { BuilderHandlerFn } from '@angular-devkit/architect';
import { json } from '@angular-devkit/core';
import { readFileSync } from 'node:fs';
import { JasmineBuilderHarness, host, setupApplicationTarget } from './setup';
const optionSchemaCache = new Map<string, json.schema.JsonSchema>();
export function describeServeBuilder<T>(
builderHandler: BuilderHandlerFn<T & json.JsonObject>,
options: { name?: string; schemaPath: string },
specDefinitions: (
harness: JasmineBuilderHarness<T>,
setupTarget: typeof setupApplicationTarget,
isViteRun: true,
) => void,
): void {
let optionSchema = optionSchemaCache.get(options.schemaPath);
if (optionSchema === undefined) {
optionSchema = JSON.parse(readFileSync(options.schemaPath, 'utf8')) as json.schema.JsonSchema;
optionSchemaCache.set(options.schemaPath, optionSchema);
}
const harness = new JasmineBuilderHarness<T>(builderHandler, host, {
builderName: options.name,
optionSchema,
});
describe(options.name || builderHandler.name, () => {
beforeEach(() => host.initialize().toPromise());
afterEach(() => host.restore().toPromise());
specDefinitions(harness, setupApplicationTarget, true);
});
}

View File

@ -0,0 +1,71 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { executeDevServer } from '../../index';
import { executeOnceAndFetch } from '../execute-fetch';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
const FETCH_HEADERS = Object.freeze({ host: 'example.com' });
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
// TODO(fix-vite): currently this is broken in vite.
(isViteRun ? xdescribe : xdescribe)('option: "allowedHosts"', () => {
beforeEach(async () => {
setupTarget(harness);
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', '');
});
it('does not allow an invalid host when option is not present', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
request: { headers: FETCH_HEADERS },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toBe('Invalid Host header');
});
it('does not allow an invalid host when option is an empty array', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
allowedHosts: [],
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
request: { headers: FETCH_HEADERS },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toBe('Invalid Host header');
});
it('allows a host when specified in the option', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
allowedHosts: ['example.com'],
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
request: { headers: FETCH_HEADERS },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('<title>');
});
});
},
);

View File

@ -0,0 +1,71 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { executeDevServer } from '../../index';
import { executeOnceAndFetch } from '../execute-fetch';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
const FETCH_HEADERS = Object.freeze({ host: 'example.com' });
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
// This option is not used when using vite.
(isViteRun ? xdescribe : xdescribe)('option: "disableHostCheck"', () => {
beforeEach(async () => {
setupTarget(harness);
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', '');
});
it('does not allow an invalid host when option is not present', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
request: { headers: FETCH_HEADERS },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toBe('Invalid Host header');
});
it('does not allow an invalid host when option is false', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
disableHostCheck: false,
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
request: { headers: FETCH_HEADERS },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toBe('Invalid Host header');
});
it('allows a host when option is true', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
disableHostCheck: true,
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
request: { headers: FETCH_HEADERS },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('<title>');
});
});
},
);

View File

@ -0,0 +1,79 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { executeDevServer } from '../../index';
import { executeOnceAndFetch } from '../execute-fetch';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
const ESBUILD_LOG_TEXT = 'Application bundle generation complete.';
const WEBPACK_LOG_TEXT = 'Compiled successfully.';
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
describe('option: "forceEsbuild"', () => {
beforeEach(async () => {
setupTarget(harness, {});
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', 'console.log("foo");');
});
it('should use build target specified build system when not present', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
forceEsbuild: undefined,
});
const { result, response, logs } = await executeOnceAndFetch(harness, '/main.js');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('console.log');
expect(logs).toContain(
jasmine.objectContaining({
message: jasmine.stringMatching(isViteRun ? ESBUILD_LOG_TEXT : WEBPACK_LOG_TEXT),
}),
);
});
it('should use build target specified build system when false', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
forceEsbuild: false,
});
const { result, response, logs } = await executeOnceAndFetch(harness, '/main.js');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('console.log');
expect(logs).toContain(
jasmine.objectContaining({
message: jasmine.stringMatching(isViteRun ? ESBUILD_LOG_TEXT : WEBPACK_LOG_TEXT),
}),
);
});
it('should always use the esbuild build system with Vite when true', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
forceEsbuild: true,
});
const { result, response, logs } = await executeOnceAndFetch(harness, '/main.js');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('console.log');
expect(logs).toContain(
jasmine.objectContaining({ message: jasmine.stringMatching(ESBUILD_LOG_TEXT) }),
);
});
});
},
);

View File

@ -0,0 +1,112 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { URL } from 'url';
import { executeDevServer } from '../../index';
import { executeOnceAndFetch } from '../execute-fetch';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
function getResultPort(result: Record<string, unknown> | undefined): string | undefined {
if (typeof result?.baseUrl !== 'string') {
fail(`Expected builder result with a string 'baseUrl' property. Received: ${result?.baseUrl}`);
return;
}
try {
return new URL(result.baseUrl).port;
} catch {
fail(`Expected a valid URL in builder result 'baseUrl' property. Received: ${result.baseUrl}`);
}
}
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
describe('option: "port"', () => {
beforeEach(async () => {
setupTarget(harness);
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', '');
});
it('uses default port (4200) when not present', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
// Base options set port to zero
port: undefined,
});
const { result, response, logs } = await executeOnceAndFetch(harness, '/');
expect(result?.success).toBeTrue();
expect(getResultPort(result)).toBe('4200');
expect(await response?.text()).toContain('<title>');
if (!isViteRun) {
expect(logs).toContain(
jasmine.objectContaining({
message: jasmine.stringMatching(/:4200/),
}),
);
}
});
it('uses a random free port when set to 0 (zero)', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
port: 0,
});
const { result, response, logs } = await executeOnceAndFetch(harness, '/');
expect(result?.success).toBeTrue();
const port = getResultPort(result);
expect(port).not.toBe('4200');
if (isViteRun) {
// Should not be default Vite port either
expect(port).not.toBe('5173');
}
expect(port).toMatch(/\d{4,6}/);
expect(await response?.text()).toContain('<title>');
if (!isViteRun) {
expect(logs).toContain(
jasmine.objectContaining({
message: jasmine.stringMatching(':' + port),
}),
);
}
});
it('uses specific port when a non-zero number is specified', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
port: 8000,
});
const { result, response, logs } = await executeOnceAndFetch(harness, '/');
expect(result?.success).toBeTrue();
expect(getResultPort(result)).toBe('8000');
expect(await response?.text()).toContain('<title>');
if (!isViteRun) {
expect(logs).toContain(
jasmine.objectContaining({
message: jasmine.stringMatching(':8000'),
}),
);
}
});
});
},
);

View File

@ -0,0 +1,99 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { executeDevServer } from '../../index';
import { executeOnceAndFetch } from '../execute-fetch';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
// TODO: Temporarily disabled pending investigation into test-only Vite not stopping when caching is enabled
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
// prebundling is not available in webpack
(isViteRun ? xdescribe : xdescribe)('option: "prebundle"', () => {
beforeEach(async () => {
setupTarget(harness);
harness.useProject('test', {
cli: {
cache: {
enabled: true,
},
},
});
// Application code is not needed for these tests
await harness.writeFile(
'src/main.ts',
`
import { VERSION as coreVersion } from '@angular/core';
import { VERSION as platformVersion } from '@angular/platform-browser';
console.log(coreVersion);
console.log(platformVersion);
`,
);
});
it('should prebundle dependencies when option is not present', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, content } = await executeOnceAndFetch(harness, '/main.js');
expect(result?.success).toBeTrue();
expect(content).toContain('vite/deps/@angular_core.js');
expect(content).not.toContain('node_modules/@angular/core/');
});
it('should prebundle dependencies when option is set to true', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
prebundle: true,
});
const { result, content } = await executeOnceAndFetch(harness, '/main.js');
expect(result?.success).toBeTrue();
expect(content).toContain('vite/deps/@angular_core.js');
expect(content).not.toContain('node_modules/@angular/core/');
});
it('should not prebundle dependencies when option is set to false', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
prebundle: false,
});
const { result, content } = await executeOnceAndFetch(harness, '/main.js');
expect(result?.success).toBeTrue();
expect(content).not.toContain('vite/deps/@angular_core.js');
expect(content).toContain('node_modules/@angular/core/');
});
it('should not prebundle specified dependency if added to exclude list', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
prebundle: { exclude: ['@angular/platform-browser'] },
});
const { result, content } = await executeOnceAndFetch(harness, '/main.js');
expect(result?.success).toBeTrue();
expect(content).toContain('vite/deps/@angular_core.js');
expect(content).not.toContain('node_modules/@angular/core/');
expect(content).not.toContain('vite/deps/@angular_platform-browser.js');
expect(content).toContain('node_modules/@angular/platform-browser/');
});
});
},
);

View File

@ -0,0 +1,323 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { 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, BuilderHarness } from '../setup';
describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget, isVite) => {
describe('option: "proxyConfig"', () => {
beforeEach(async () => {
setupTarget(harness);
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', '');
});
it('proxies requests based on the `.json` proxy configuration file provided in the option', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.json',
});
const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.json': `{ "/api/*": { "target": "http://127.0.0.1:${proxyServer.address.port}" } }`,
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
it('proxies requests based on the `.json` (with comments) proxy configuration file provided in the option', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.json',
});
const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.json': `
// JSON file with comments
{ "/api/*": { "target": "http://127.0.0.1:${proxyServer.address.port}" } }
`,
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
it('proxies requests based on the `.js` (CommonJS) proxy configuration file provided in the option', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.js',
});
const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.js': `module.exports = { "/api/*": { "target": "http://127.0.0.1:${proxyServer.address.port}" } }`,
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
it('proxies requests based on the `.js` (ESM) proxy configuration file provided in the option', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.js',
});
const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.js': `export default { "/api/*": { "target": "http://127.0.0.1:${proxyServer.address.port}" } }`,
'package.json': '{ "type": "module" }',
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
it('proxies requests based on the `.cjs` proxy configuration file provided in the option', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.cjs',
});
const proxyServer = await createProxyServer();
try {
const proxyAddress = proxyServer.address;
await harness.writeFiles({
'proxy.config.cjs': `module.exports = { "/api/*": { "target": "http://127.0.0.1:${proxyAddress.port}" } }`,
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
it('proxies requests based on the `.mjs` proxy configuration file provided in the option', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.mjs',
});
const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.mjs': `export default { "/api/*": { "target": "http://127.0.0.1:${proxyServer.address.port}" } }`,
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
it('supports the Webpack array form of the configuration file', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.json',
});
const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.json': `[ { "context": ["/api", "/abc"], "target": "http://127.0.0.1:${proxyServer.address.port}" } ]`,
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
it('throws an error when proxy configuration file cannot be found', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'INVALID.json',
});
const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
expect(result).toBeUndefined();
expect(error).toEqual(
jasmine.objectContaining({
message: jasmine.stringMatching('INVALID\\.json does not exist'),
}),
);
});
it('throws an error when JSON proxy configuration file cannot be parsed', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.json',
});
// Create a JSON file with a parse error (target property has no value)
await harness.writeFiles({
'proxy.config.json': `
// JSON file with comments
{ "/api/*": { "target": } }
`,
});
const { result, error } = await harness.executeOnce({ outputLogsOnException: false });
expect(result).toBeUndefined();
expect(error).toEqual(
jasmine.objectContaining({
message: jasmine.stringMatching('contains parse errors:\\n\\[3, 35\\] ValueExpected'),
}),
);
});
it('supports negation of globs', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.json',
});
const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.json': `
{ "!something/**/*": { "target": "http://127.0.0.1:${proxyServer.address.port}" } }
`,
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
/**
* ****************************************************************************************************
* ********************************** Below only Vite specific tests **********************************
* ****************************************************************************************************
*/
if (isVite) {
viteOnlyTests(harness);
}
});
});
/**
* 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<void>((resolve) => proxyServer.listen(0, '127.0.0.1', resolve));
return {
address: proxyServer.address() as import('net').AddressInfo,
close: () => new Promise<void>((resolve) => proxyServer.close(() => resolve())),
};
}
/**
* Vite specific tests
*/
function viteOnlyTests(harness: BuilderHarness<unknown>): void {
it('proxies support regexp as context', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.json',
});
const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.json': `
{ "^/api/.*": { "target": "http://127.0.0.1:${proxyServer.address.port}" } }
`,
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
it('proxies support negated regexp as context', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.json',
});
const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.json': `
{ "^\\/(?!something).*": { "target": "http://127.0.0.1:${proxyServer.address.port}" } }
`,
});
const { result, response } = await executeOnceAndFetch(harness, '/api/test');
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
}

View File

@ -0,0 +1,71 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { executeDevServer } from '../../index';
import { executeOnceAndFetch } from '../execute-fetch';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
const FETCH_HEADERS = Object.freeze({ host: 'example.com' });
describeServeBuilder(
executeDevServer,
DEV_SERVER_BUILDER_INFO,
(harness, setupTarget, isViteRun) => {
// This option is not used when using vite.
(isViteRun ? xdescribe : xdescribe)('option: "publicHost"', () => {
beforeEach(async () => {
setupTarget(harness);
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', '');
});
it('does not allow an invalid host when option is not present', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
request: { headers: FETCH_HEADERS },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toBe('Invalid Host header');
});
it('does not allow an invalid host when option is a different host', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
publicHost: 'example.net',
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
request: { headers: FETCH_HEADERS },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toBe('Invalid Host header');
});
it('allows a host when option is set to used host', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
publicHost: 'example.com',
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
request: { headers: FETCH_HEADERS },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('<title>');
});
});
},
);

View File

@ -0,0 +1,120 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { URL } from 'url';
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, isViteRun) => {
describe('option: "servePath"', () => {
beforeEach(async () => {
setupTarget(harness, {
assets: ['src/assets'],
});
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', 'console.log("foo");');
});
it('serves application at the root when option is not present', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
});
const { result, response } = await executeOnceAndFetch(harness, '/main.js');
expect(result?.success).toBeTrue();
const baseUrl = new URL(`${result?.baseUrl}`);
expect(baseUrl.pathname).toBe('/');
expect(await response?.text()).toContain('console.log');
});
it('serves application at specified path when option is used', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
servePath: 'test',
});
const { result, response } = await executeOnceAndFetch(harness, '/test/main.js');
expect(result?.success).toBeTrue();
const baseUrl = new URL(`${result?.baseUrl}/`);
expect(baseUrl.pathname).toBe('/test/');
expect(await response?.text()).toContain('console.log');
});
// TODO(fix-vite): currently this is broken in vite.
(isViteRun ? xit : it)('does not rewrite from root when option is used', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
servePath: 'test',
});
const { result, response } = await executeOnceAndFetch(harness, '/', {
// fallback processing requires an accept header
request: { headers: { accept: 'text/html' } },
});
expect(result?.success).toBeTrue();
expect(response?.status).toBe(404);
});
it('does not rewrite from path outside serve path when option is used', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
servePath: 'test',
});
const { result, response } = await executeOnceAndFetch(harness, '/api/', {
// fallback processing requires an accept header
request: { headers: { accept: 'text/html' } },
});
expect(result?.success).toBeTrue();
expect(response?.status).toBe(404);
});
it('rewrites from path inside serve path when option is used', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
servePath: 'test',
});
const { result, response } = await executeOnceAndFetch(harness, '/test/inside', {
// fallback processing requires an accept header
request: { headers: { accept: 'text/html' } },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('<title>');
});
it('serves assets at specified path when option is used', async () => {
await harness.writeFile('src/assets/test.txt', 'hello world!');
harness.useTarget('serve', {
...BASE_OPTIONS,
servePath: 'test',
});
const { result, response } = await executeOnceAndFetch(harness, '/test/assets/test.txt', {
// fallback processing requires an accept header
request: { headers: { accept: 'text/html' } },
});
expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('hello world');
});
});
},
);

View File

@ -0,0 +1,121 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { TimeoutError, concatMap, count, take, timeout } from 'rxjs';
import { executeDevServer } from '../../index';
import { describeServeBuilder } from '../jasmine-helpers';
import { BASE_OPTIONS, BUILD_TIMEOUT, DEV_SERVER_BUILDER_INFO } from '../setup';
describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => {
describe('Option: "watch"', () => {
beforeEach(() => {
setupTarget(harness);
});
it('does not wait for file changes when false', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
watch: false,
});
await harness
.execute()
.pipe(
timeout(BUILD_TIMEOUT),
concatMap(async ({ result }, index) => {
expect(result?.success).toBe(true);
switch (index) {
case 0:
await harness.modifyFile(
'src/main.ts',
(content) => content + 'console.log("abcd1234");',
);
break;
case 1:
fail('Expected files to not be watched.');
break;
}
}),
take(2),
)
.toPromise()
.catch((error) => {
// Timeout is expected if watching is disabled
if (error instanceof TimeoutError) {
return;
}
throw error;
});
});
it('watches for file changes when not present', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
watch: undefined,
});
const buildCount = await harness
.execute()
.pipe(
timeout(BUILD_TIMEOUT),
concatMap(async ({ result }, index) => {
expect(result?.success).toBe(true);
switch (index) {
case 0:
await harness.modifyFile(
'src/main.ts',
(content) => content + 'console.log("abcd1234");',
);
break;
case 1:
break;
}
}),
take(2),
count(),
)
.toPromise();
expect(buildCount).toBe(2);
});
it('watches for file changes when true', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
watch: true,
});
const buildCount = await harness
.execute()
.pipe(
timeout(BUILD_TIMEOUT),
concatMap(async ({ result }, index) => {
expect(result?.success).toBe(true);
switch (index) {
case 0:
await harness.modifyFile(
'src/main.ts',
(content) => content + 'console.log("abcd1234");',
);
break;
case 1:
break;
}
}),
take(2),
count(),
)
.toPromise();
expect(buildCount).toBe(2);
});
});
});

View File

@ -0,0 +1,104 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { json } from '@angular-devkit/core';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { BuilderHarness } from '../../../../../../../modules/testing/builder/src';
import { ApplicationBuilderOptions as AppilicationSchema, buildApplication } from '@angular/build';
import { Schema } from '../schema';
// TODO: Consider using package.json imports field instead of relative path
// after the switch to rules_js.
export * from '../../../../../../../modules/testing/builder/src';
// TODO: Remove and use import after Vite-based dev server is moved to new package
export const APPLICATION_BUILDER_INFO = Object.freeze({
name: '@angular-devkit/build-angular:application',
schemaPath: path.join(
path.dirname(require.resolve('@angular/build/package.json')),
'src/builders/application/schema.json',
),
});
/**
* Contains all required application builder fields.
* Also disables progress reporting to minimize logging output.
*/
export const APPLICATION_BASE_OPTIONS = Object.freeze<AppilicationSchema>({
index: 'src/index.html',
browser: 'src/main.ts',
outputPath: 'dist',
tsConfig: 'src/tsconfig.app.json',
progress: false,
// Disable optimizations
optimization: false,
// Enable polling (if a test enables watch mode).
// This is a workaround for bazel isolation file watch not triggering in tests.
poll: 100,
});
export const DEV_SERVER_BUILDER_INFO = Object.freeze({
name: '@angular-devkit/build-angular:dev-server',
schemaPath: __dirname + '/../schema.json',
});
/**
* Contains all required dev-server builder fields.
* The port is also set to zero to ensure a free port is used for each test which
* supports parallel test execution.
*/
export const BASE_OPTIONS = Object.freeze<Schema>({
buildTarget: 'test:build',
port: 0,
// Watch is not supported for testing in vite as currently there is no teardown logic to stop the watchers.
watch: false,
});
/**
* Maximum time for single build/rebuild
* This accounts for CI variability.
*/
export const BUILD_TIMEOUT = 25_000;
/**
* Cached application builder option schema
*/
let applicationSchema: json.schema.JsonSchema | undefined;
/**
* Adds a `build` target to a builder test harness for the application builder with the base options
* used by the application builder tests.
*
* @param harness The builder harness to use when setting up the application builder target
* @param extraOptions The additional options that should be used when executing the target.
*/
export function setupApplicationTarget<T>(
harness: BuilderHarness<T>,
extraOptions?: Partial<AppilicationSchema>,
): void {
applicationSchema ??= JSON.parse(
readFileSync(APPLICATION_BUILDER_INFO.schemaPath, 'utf8'),
) as json.schema.JsonSchema;
harness.withBuilderTarget(
'build',
buildApplication,
{
...APPLICATION_BASE_OPTIONS,
...extraOptions,
},
{
builderName: APPLICATION_BUILDER_INFO.name,
optionSchema: applicationSchema,
},
);
}

View File

@ -7,7 +7,6 @@
*/
import type { BuilderContext } from '@angular-devkit/architect';
import type { json } from '@angular-devkit/core';
import type { Plugin } from 'esbuild';
import assert from 'node:assert';
import { readFile } from 'node:fs/promises';
@ -17,15 +16,13 @@ import { createAngularMemoryPlugin } from '../../tools/vite/angular-memory-plugi
import { createAngularLocaleDataPlugin } from '../../tools/vite/i18n-locale-plugin';
import { loadProxyConfiguration, normalizeSourceMaps } from '../../utils';
import { loadEsmModule } from '../../utils/load-esm';
import { buildEsbuildBrowser } from '../browser-esbuild';
import { Schema as BrowserBuilderOptions } from '../browser-esbuild/schema';
import { ApplicationBuilderOutput } from '../application';
import {
type ApplicationBuilderInternalOptions,
type BuildOutputFile,
BuildOutputFileType,
type ExternalResultMetadata,
JavaScriptTransformer,
buildApplicationInternal,
createRxjsEsmResolutionPlugin,
getFeatureSupport,
getSupportedBrowsers,
@ -43,6 +40,12 @@ interface OutputFileRecord {
servable: boolean;
}
export type BuilderAction = (
options: ApplicationBuilderInternalOptions,
context: BuilderContext,
plugins?: Plugin[],
) => AsyncIterable<ApplicationBuilderOutput>;
/**
* Build options that are also present on the dev server but are only passed
* to the build.
@ -53,6 +56,7 @@ const CONVENIENCE_BUILD_OPTIONS = ['watch', 'poll', 'verbose'] as const;
export async function* serveWithVite(
serverOptions: NormalizedDevServerOptions,
builderName: string,
builderAction: BuilderAction,
context: BuilderContext,
transformers?: {
indexHtml?: (content: string) => Promise<string>;
@ -76,10 +80,11 @@ export async function* serveWithVite(
}
}
const browserOptions = await context.validateOptions<json.JsonObject & BrowserBuilderOptions>(
// TODO: Adjust architect to not force a JsonObject derived return type
const browserOptions = (await context.validateOptions(
rawBrowserOptions,
builderName,
);
)) as unknown as ApplicationBuilderInternalOptions;
if (browserOptions.prerender || browserOptions.ssr) {
// Disable prerendering if enabled and force SSR.
@ -94,7 +99,7 @@ export async function* serveWithVite(
}
// Set all packages as external to support Vite's prebundle caching
browserOptions.externalPackages = serverOptions.prebundle as json.JsonValue;
browserOptions.externalPackages = serverOptions.prebundle;
const baseHref = browserOptions.baseHref;
if (serverOptions.servePath === undefined && baseHref !== undefined) {
@ -161,25 +166,8 @@ export async function* serveWithVite(
deferred?.();
});
const build =
builderName === '@angular-devkit/build-angular:browser-esbuild'
? buildEsbuildBrowser.bind(
undefined,
browserOptions,
context,
{ write: false },
extensions?.buildPlugins,
)
: buildApplicationInternal.bind(
undefined,
browserOptions as ApplicationBuilderInternalOptions,
context,
{ write: false },
{ codePlugins: extensions?.buildPlugins },
);
// TODO: Switch this to an architect schedule call when infrastructure settings are supported
for await (const result of build()) {
for await (const result of builderAction(browserOptions, context, extensions?.buildPlugins)) {
assert(result.outputFiles, 'Builder did not provide result files.');
// If build failed, nothing to serve

View File

@ -13,3 +13,9 @@ export {
} from './builders/application';
export { type BuildOutputFile, BuildOutputFileType } from './tools/esbuild/bundler-context';
export type { BuildOutputAsset } from './tools/esbuild/bundler-execution-result';
export {
executeDevServerBuilder,
DevServerBuilderOptions,
DevServerBuilderOutput,
} from './builders/dev-server';

View File

@ -16,6 +16,7 @@
// Builders
export { buildApplicationInternal } from './builders/application';
export { ApplicationBuilderInternalOptions } from './builders/application/options';
export { serveWithVite } from './builders/dev-server/vite-server';
// Tools
export * from './tools/babel/plugins';
@ -26,6 +27,7 @@ export { SassWorkerImplementation } from './tools/sass/sass-service';
// Utilities
export * from './utils/bundle-calculator';
export { checkPort } from './utils/check-port';
export { deleteOutputDir } from './utils/delete-output-dir';
export { I18nOptions, createI18nOptions, loadTranslations } from './utils/i18n-options';
export {
@ -40,15 +42,10 @@ export {
InlineCriticalCssProcessor,
type InlineCriticalCssProcessorOptions,
} from './utils/index-file/inline-critical-css';
export { loadProxyConfiguration } from './utils/load-proxy-config';
export { type TranslationLoader, createTranslationLoader } from './utils/load-translations';
export { purgeStaleBuildCache } from './utils/purge-cache';
export { augmentAppWithServiceWorker } from './utils/service-worker';
export { BundleStats, generateBuildStatsTable } from './utils/stats-table';
export { getSupportedBrowsers } from './utils/supported-browsers';
export { assertCompatibleAngularVersion } from './utils/version';
// Required for Vite-based dev server only
export { createRxjsEsmResolutionPlugin } from './tools/esbuild/rxjs-esm-resolution-plugin';
export { JavaScriptTransformer } from './tools/esbuild/javascript-transformer';
export { getFeatureSupport, isZonelessApp } from './tools/esbuild/utils';
export { renderPage } from './utils/server-rendering/render-page';

View File

@ -7,13 +7,13 @@
*/
import remapping, { SourceMapInput } from '@ampproject/remapping';
import { renderPage } from '@angular/build/private';
import { lookup as lookupMimeType } from 'mrmime';
import assert from 'node:assert';
import { readFile } from 'node:fs/promises';
import { ServerResponse } from 'node:http';
import { dirname, extname, join, relative } from 'node:path';
import type { Connect, Plugin } from 'vite';
import { renderPage } from '../../utils/server-rendering/render-page';
export interface AngularMemoryPluginOptions {
workspaceRoot: string;

View File

@ -9,3 +9,4 @@
export * from './normalize-asset-patterns';
export * from './normalize-optimization';
export * from './normalize-source-maps';
export * from './load-proxy-config';

View File

@ -6,15 +6,14 @@
* found in the LICENSE file at https://angular.io/license
*/
import type { DevServerBuilderOutput } from '@angular/build';
import { type IndexHtmlTransform, checkPort, purgeStaleBuildCache } from '@angular/build/private';
import type { BuilderContext } from '@angular-devkit/architect';
import type { Plugin } from 'esbuild';
import type http from 'node:http';
import { EMPTY, Observable, defer, switchMap } from 'rxjs';
import type { ExecutionTransformer } from '../../transforms';
import { checkPort } from '../../utils/check-port';
import { type IndexHtmlTransform, purgeStaleBuildCache } from './internal';
import { normalizeOptions } from './options';
import type { DevServerBuilderOutput } from './output';
import type { Schema as DevServerBuilderOptions } from './schema';
/**
@ -93,9 +92,23 @@ export function execute(
);
}
return defer(() => import('./vite-server')).pipe(
switchMap(({ serveWithVite }) =>
serveWithVite(normalizedOptions, builderName, context, transforms, extensions),
return defer(() =>
Promise.all([import('@angular/build/private'), import('../browser-esbuild')]),
).pipe(
switchMap(([{ serveWithVite, buildApplicationInternal }, { buildEsbuildBrowser }]) =>
serveWithVite(
normalizedOptions,
builderName,
(options, context, codePlugins) => {
return builderName === '@angular-devkit/build-angular:browser-esbuild'
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
buildEsbuildBrowser(options as any, context, { write: false }, codePlugins)
: buildApplicationInternal(options, context, { write: false }, { codePlugins });
},
context,
transforms,
extensions,
),
),
);
}
@ -178,9 +191,11 @@ case.
function isEsbuildBased(
builderName: string,
): builderName is
| '@angular/build:application'
| '@angular-devkit/build-angular:application'
| '@angular-devkit/build-angular:browser-esbuild' {
if (
builderName === '@angular/build:application' ||
builderName === '@angular-devkit/build-angular:application' ||
builderName === '@angular-devkit/build-angular:browser-esbuild'
) {

View File

@ -6,9 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/
import { DevServerBuilderOutput } from '@angular/build';
import { createBuilder } from '@angular-devkit/architect';
import { execute } from './builder';
import { DevServerBuilderOutput } from './output';
import { Schema as DevServerBuilderOptions } from './schema';
export { DevServerBuilderOptions, DevServerBuilderOutput, execute as executeDevServerBuilder };

View File

@ -1,22 +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.io/license
*/
export { BuildOutputFile, BuildOutputFileType } from '@angular/build';
export {
type ApplicationBuilderInternalOptions,
type ExternalResultMetadata,
JavaScriptTransformer,
buildApplicationInternal,
createRxjsEsmResolutionPlugin,
getFeatureSupport,
getSupportedBrowsers,
isZonelessApp,
transformSupportedBrowsersToTargets,
type IndexHtmlTransform,
purgeStaleBuildCache,
} from '@angular/build/private';

View File

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import { loadProxyConfiguration } from '@angular/build/private';
import {
BuilderContext,
BuilderOutput,
@ -41,7 +42,6 @@ import {
zip,
} from 'rxjs';
import * as url from 'url';
import { loadProxyConfiguration } from '../../utils';
import { Schema } from './schema';
import { getAvailablePort, spawnAsObservable, waitUntilServerIsListening } from './utils';

View File

@ -7,9 +7,8 @@
*/
export * from './default-progress';
export { deleteOutputDir } from '@angular/build/private';
export { deleteOutputDir, loadProxyConfiguration } from '@angular/build/private';
export * from './run-module-as-observable-fork';
export * from './load-proxy-config';
export * from './normalize-file-replacements';
export * from './normalize-asset-patterns';
export * from './normalize-source-maps';

View File

@ -130,8 +130,7 @@
tslib "^2.3.0"
"@angular/bazel@https://github.com/angular/bazel-builds.git#d4cd1ac66633528be9ad967b06a00d326446729e":
version "18.0.0-next.5+sha-e1eae84"
uid d4cd1ac66633528be9ad967b06a00d326446729e
version "18.0.0-next.5"
resolved "https://github.com/angular/bazel-builds.git#d4cd1ac66633528be9ad967b06a00d326446729e"
dependencies:
"@microsoft/api-extractor" "^7.24.2"
@ -148,7 +147,6 @@
"@angular/build-tooling@https://github.com/angular/dev-infra-private-build-tooling-builds.git#ba23169e282840cfac66488ff05c4258e20d4abc":
version "0.0.0-10f65a101457091d4bc3500963d3a9630d55f7bd"
uid ba23169e282840cfac66488ff05c4258e20d4abc
resolved "https://github.com/angular/dev-infra-private-build-tooling-builds.git#ba23169e282840cfac66488ff05c4258e20d4abc"
dependencies:
"@angular-devkit/build-angular" "17.3.3"
@ -316,7 +314,6 @@
"@angular/ng-dev@https://github.com/angular/dev-infra-private-ng-dev-builds.git#5f1f27c5ad034501ba7de855c0e2579bd131d43f":
version "0.0.0-10f65a101457091d4bc3500963d3a9630d55f7bd"
uid "5f1f27c5ad034501ba7de855c0e2579bd131d43f"
resolved "https://github.com/angular/dev-infra-private-ng-dev-builds.git#5f1f27c5ad034501ba7de855c0e2579bd131d43f"
dependencies:
"@yarnpkg/lockfile" "^1.1.0"
@ -12300,7 +12297,6 @@ sass@1.75.0, sass@^1.69.5:
"sauce-connect-proxy@https://saucelabs.com/downloads/sc-4.9.1-linux.tar.gz":
version "0.0.0"
uid "9310bc860f7870a1f872b11c4dc6073a1ad34e5e"
resolved "https://saucelabs.com/downloads/sc-4.9.1-linux.tar.gz#9310bc860f7870a1f872b11c4dc6073a1ad34e5e"
saucelabs@^1.5.0:
@ -13871,6 +13867,17 @@ vite@5.1.5:
optionalDependencies:
fsevents "~2.3.3"
vite@5.2.10:
version "5.2.10"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.10.tgz#2ac927c91e99d51b376a5c73c0e4b059705f5bd7"
integrity sha512-PAzgUZbP7msvQvqdSD+ErD5qGnSFiGOoWmV5yAKUEI0kdhjbH6nMWVyZQC/hSc4aXwc0oJ9aEdIiF9Oje0JFCw==
dependencies:
esbuild "^0.20.1"
postcss "^8.4.38"
rollup "^4.13.0"
optionalDependencies:
fsevents "~2.3.3"
vite@5.2.9:
version "5.2.9"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.9.tgz#cd9a356c6ff5f7456c09c5ce74068ffa8df743d9"