Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions goldens/public-api/angular/build/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export enum BuildOutputFileType {

// @public
export interface DevServerBuilderOptions {
allowedHosts?: AllowedHosts;
buildTarget: string;
headers?: {
[key: string]: string;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
"undici": "6.19.7",
"verdaccio": "5.32.1",
"verdaccio-auth-memory": "^10.0.0",
"vite": "5.4.6",
"vite": "5.4.14",
"watchpack": "2.4.1",
"webpack": "5.94.0",
"webpack-dev-middleware": "7.4.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"rollup": "4.22.4",
"sass": "1.77.6",
"semver": "7.6.3",
"vite": "5.4.6",
"vite": "5.4.14",
"watchpack": "2.4.1"
},
"peerDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions packages/angular/build/src/builders/dev-server/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export async function normalizeOptions(
sslCert,
sslKey,
prebundle,
allowedHosts,
} = options;

// Return all the normalized options
Expand All @@ -128,5 +129,6 @@ export async function normalizeOptions(
// Prebundling defaults to true but requires caching to function
prebundle: cacheOptions.enabled && !optimization.scripts && prebundle,
inspect,
allowedHosts: allowedHosts ? allowedHosts : [],
};
}
17 changes: 17 additions & 0 deletions packages/angular/build/src/builders/dev-server/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@
"type": "string",
"description": "SSL certificate to use for serving HTTPS."
},
"allowedHosts": {
"description": "The hosts that can access the development server. This option sets the Vite option of the same name. For further details: https://vite.dev/config/server-options.html#server-allowedhosts",
"default": [],
"oneOf": [
{
"type": "array",
"description": "List of hosts that are allowed to access the development server.",
"items": {
"type": "string"
}
},
{
"type": "boolean",
"description": "Indicates that all hosts are allowed. This is not recommended and a security risk."
}
]
},
"headers": {
"type": "object",
"description": "Custom HTTP headers to be added to all responses.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*/

import { lastValueFrom, mergeMap, take, timeout } from 'rxjs';
import { URL } from 'url';
import { get, IncomingMessage, RequestOptions } from 'node:http';
import { text } from 'node:stream/consumers';
import {
BuilderHarness,
BuilderHarnessExecutionOptions,
Expand Down Expand Up @@ -41,3 +42,48 @@ export async function executeOnceAndFetch<T>(
),
);
}

/**
* Executes the builder and then immediately performs a GET request
* via the Node.js `http` builtin module. This is useful for cases
* where the `fetch` API is limited such as testing different `Host`
* header values with the development server.
* The `fetch` based alternative is preferred otherwise.
*
* @param harness A builder harness instance.
* @param url The URL string to get.
* @param options An options object.
*/
export async function executeOnceAndGet<T>(
harness: BuilderHarness<T>,
url: string,
options?: Partial<BuilderHarnessExecutionOptions> & { request?: RequestOptions },
): Promise<BuilderHarnessExecutionResult & { response?: IncomingMessage; content?: string }> {
return lastValueFrom(
harness.execute().pipe(
timeout(30_000),
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);

response = await new Promise<IncomingMessage>((resolve) =>
get(resolvedUrl, options?.request ?? {}, resolve),
);

if (response.statusCode === 200) {
content = await text(response);
}

response.resume();
}

return { ...executionResult, response, content };
}),
take(1),
),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { executeDevServer } from '../../index';
import { executeOnceAndGet } 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) => {
describe('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 executeOnceAndGet(harness, '/', {
request: { headers: FETCH_HEADERS },
});

expect(result?.success).toBeTrue();
expect(response?.statusCode).toBe(403);
});

it('does not allow an invalid host when option is an empty array', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
allowedHosts: [],
});

const { result, response } = await executeOnceAndGet(harness, '/', {
request: { headers: FETCH_HEADERS },
});

expect(result?.success).toBeTrue();
expect(response?.statusCode).toBe(403);
});

it('allows a host when specified in the option', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
allowedHosts: ['example.com'],
});

const { result, content } = await executeOnceAndGet(harness, '/', {
request: { headers: FETCH_HEADERS },
});

expect(result?.success).toBeTrue();
expect(content).toContain('<title>');
});

it('allows a host when option is true', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
allowedHosts: true,
});

const { result, content } = await executeOnceAndGet(harness, '/', {
request: { headers: FETCH_HEADERS },
});

expect(result?.success).toBeTrue();
expect(content).toContain('<title>');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,7 @@ export async function setupServer(
strictPort: true,
host: serverOptions.host,
open: serverOptions.open,
allowedHosts: serverOptions.allowedHosts,
headers: serverOptions.headers,
proxy,
cors: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,21 @@ export function execute(
);
}

// New build system uses Vite's allowedHost option convention of true for disabling host checks
if (normalizedOptions.disableHostCheck) {
(normalizedOptions as unknown as { allowedHosts: true }).allowedHosts = true;
} else {
normalizedOptions.allowedHosts ??= [];
}

return defer(() =>
Promise.all([import('@angular/build/private'), import('../browser-esbuild')]),
).pipe(
switchMap(([{ serveWithVite, buildApplicationInternal }, { convertBrowserOptions }]) =>
serveWithVite(
normalizedOptions,
normalizedOptions as typeof normalizedOptions & {
allowedHosts: true | string[];
},
builderName,
(options, context, codePlugins) => {
return builderName === '@angular-devkit/build-angular:browser-esbuild'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
},
"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.",
"description": "List of hosts that are allowed to access the dev server.",
"default": [],
"items": {
"type": "string"
Expand All @@ -85,7 +85,7 @@
},
"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.",
"description": "Don't verify connected clients are part of allowed hosts.",
"default": false
},
"hmr": {
Expand Down
47 changes: 45 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ __metadata:
rollup: "npm:4.22.4"
sass: "npm:1.77.6"
semver: "npm:7.6.3"
vite: "npm:5.4.6"
vite: "npm:5.4.14"
watchpack: "npm:2.4.1"
peerDependencies:
"@angular/compiler-cli": ^18.0.0
Expand Down Expand Up @@ -805,7 +805,7 @@ __metadata:
undici: "npm:6.19.7"
verdaccio: "npm:5.32.1"
verdaccio-auth-memory: "npm:^10.0.0"
vite: "npm:5.4.6"
vite: "npm:5.4.14"
watchpack: "npm:2.4.1"
webpack: "npm:5.94.0"
webpack-dev-middleware: "npm:7.4.2"
Expand Down Expand Up @@ -18053,6 +18053,49 @@ __metadata:
languageName: node
linkType: hard

"vite@npm:5.4.14":
version: 5.4.14
resolution: "vite@npm:5.4.14"
dependencies:
esbuild: "npm:^0.21.3"
fsevents: "npm:~2.3.3"
postcss: "npm:^8.4.43"
rollup: "npm:^4.20.0"
peerDependencies:
"@types/node": ^18.0.0 || >=20.0.0
less: "*"
lightningcss: ^1.21.0
sass: "*"
sass-embedded: "*"
stylus: "*"
sugarss: "*"
terser: ^5.4.0
dependenciesMeta:
fsevents:
optional: true
peerDependenciesMeta:
"@types/node":
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
bin:
vite: bin/vite.js
checksum: 10c0/8842933bd70ca6a98489a0bb9c8464bec373de00f9a97c8c7a4e64b24d15c88bfaa8c1acb38a68c3e5eb49072ffbccb146842c2d4edcdd036a9802964cffe3d1
languageName: node
linkType: hard

"vite@npm:5.4.6":
version: 5.4.6
resolution: "vite@npm:5.4.6"
Expand Down
Loading