Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,56 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
await otherProxyServer.close();
}
});

it('matches glob entries against the URL pathname when a query string is present', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.json',
});

const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.json': `
{
"/api/test/**": { "target": "http://127.0.0.1:${proxyServer.address.port}" }
}
`,
});

const { result, response } = await executeOnceAndFetch(harness, '/api/test?q=angular');

expect(result?.success).toBeTrue();
expect(await response?.text()).toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});

it('does not match glob wildcards against query string parameters', async () => {
harness.useTarget('serve', {
...BASE_OPTIONS,
proxyConfig: 'proxy.config.json',
});

const proxyServer = await createProxyServer();
try {
await harness.writeFiles({
'proxy.config.json': `
{
"/api/*.json": { "target": "http://127.0.0.1:${proxyServer.address.port}" }
}
`,
});

const { result, response } = await executeOnceAndFetch(harness, '/api/test?file=data.json');

expect(result?.success).toBeTrue();
expect(await response?.text()).not.toContain('TEST_API_RETURN');
} finally {
await proxyServer.close();
}
});
});
});

Expand All @@ -340,7 +390,7 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
*/
async function createProxyServer(body = 'TEST_API_RETURN') {
const proxyServer = createServer((request, response) => {
if (request.url?.endsWith('/test')) {
if (request.url?.split('?')[0].endsWith('/test')) {
response.writeHead(200);
response.end(body);
} else {
Expand Down
32 changes: 31 additions & 1 deletion packages/angular/build/src/utils/load-proxy-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ function normalizeProxyConfiguration(
// since Vite proxies a request with the first entry that matches it.
const orderedProxy: Record<string, object> = {};
for (const [key, value] of Object.entries(normalizedProxy)) {
const context = key[0] !== '^' && isDynamicPattern(key) ? makeRegExpFromGlob(key).source : key;
const context = key[0] !== '^' && isDynamicPattern(key) ? makeUrlRegExpFromGlob(key) : key;
orderedProxy[context] = value;
}
normalizedProxy = orderedProxy;
Expand Down Expand Up @@ -151,6 +151,36 @@ function normalizeProxyConfiguration(
return normalizedProxy;
}

/**
* Converts a glob pattern to a regular expression source string that matches
* the pathname portion of a request URL while allowing an optional query string.
*
* Vite tests regex proxy keys against `req.url`, which includes the query string,
* whereas `picomatch` generates regexes assuming the input is only a pathname.
*/
function makeUrlRegExpFromGlob(glob: string): string {
const { source } = makeRegExpFromGlob(glob);

return (
source
// Ensure picomatch's "at least one character" lookahead does not
// treat the `?` query delimiter as a pathname character (e.g., `/api/*` on `/api/?a=1`).
.replaceAll('(?=.)', '(?=[^?])')
// Prevent single-segment wildcards (`*` and `?`) from matching
// across `?` into the query string (e.g., `/api/*.json` on `/api/users?file=data.json`).
.replaceAll('[^/]', '[^/?]')
// Prevent leading-dot-aware `?` wildcards from matching the `?` delimiter.
.replaceAll('[^.\\/]', '[^.\\/?]')
// Prevent multi-segment globstars (`**`) from matching across `?` into the query string
// (e.g., `/api/**/users` on `/api/other?redirect=/users`).
.replaceAll('\\.).)*?', '\\.)[^?])*?')
// Replace unescaped end-of-input anchors (`$`, both at the end of the pattern and inside
// internal alternation groups for trailing `/**` or negated globs) so an optional query
// string is permitted after the matched pathname.
.replace(/(?<!\\)\$/g, '(?:\\?.*)?$')
Comment thread
clydin marked this conversation as resolved.
);
}

function pathRewriter(pathRewriteEntries: [RegExp, string][], path: string): string {
for (const [pattern, value] of pathRewriteEntries) {
const updated = path.replace(pattern, value);
Expand Down
129 changes: 129 additions & 0 deletions packages/angular/build/src/utils/load-proxy-config_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import assert from 'node:assert';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { loadProxyConfiguration } from './load-proxy-config';
Comment thread
clydin marked this conversation as resolved.

describe('loadProxyConfiguration', () => {
let tempDir: string;

beforeEach(async () => {
const baseTmpDir = process.env['TEST_TMPDIR'];
assert(baseTmpDir, 'TEST_TMPDIR is not set');
tempDir = await mkdtemp(join(baseTmpDir, 'angular-proxy-config-spec-'));
});
Comment thread
clydin marked this conversation as resolved.

afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});

async function createGlobMatcher(glob: string): Promise<(url: string) => boolean> {
const configPath = join(tempDir, 'proxy.conf.json');
await writeFile(configPath, JSON.stringify({ [glob]: { target: 'http://localhost:3000' } }));
const proxy = await loadProxyConfiguration(tempDir, 'proxy.conf.json');
assert(proxy, 'Expected proxy configuration to be loaded');
const keys = Object.keys(proxy);
expect(keys.length).toBe(1);
const regex = new RegExp(keys[0]);

return (url: string) => regex.test(url);
}

describe('glob to RegExp conversion with query strings', () => {
it('handles trailing globstar (/**) with and without query strings', async () => {
const matches = await createGlobMatcher('/search/**');

expect(matches('/search')).toBeTrue();
expect(matches('/search?q=angular')).toBeTrue();
expect(matches('/search/')).toBeTrue();
expect(matches('/search/?q=angular')).toBeTrue();
expect(matches('/search/items')).toBeTrue();
expect(matches('/search/items?q=angular')).toBeTrue();
expect(matches('/search/items?callback=/.hidden')).toBeTrue();

expect(matches('/searching')).toBeFalse();
expect(matches('/searching?q=angular')).toBeFalse();
expect(matches('/other?redirect=/search')).toBeFalse();
});

it('handles single-segment wildcard (*) without matching across slashes or query strings', async () => {
const matches = await createGlobMatcher('/api/*');

expect(matches('/api/users')).toBeTrue();
expect(matches('/api/users?id=1')).toBeTrue();
expect(matches('/api/users?redirect=/home/page')).toBeTrue();

expect(matches('/api')).toBeFalse();
expect(matches('/api/')).toBeFalse();
expect(matches('/api/?a=1')).toBeFalse();
expect(matches('/api/users/profile')).toBeFalse();
expect(matches('/api/users/profile?a=1')).toBeFalse();
});

it('handles suffix globs (*.json) without matching query parameters', async () => {
const matches = await createGlobMatcher('/api/*.json');

expect(matches('/api/data.json')).toBeTrue();
expect(matches('/api/data.json?v=1')).toBeTrue();

expect(matches('/api/users')).toBeFalse();
expect(matches('/api/users?file=data.json')).toBeFalse();
expect(matches('/api/.json?v=1')).toBeFalse();
});

it('handles middle globstar (/**/) without matching paths in query parameters', async () => {
const matches = await createGlobMatcher('/api/**/users');

expect(matches('/api/users')).toBeTrue();
expect(matches('/api/users?page=1')).toBeTrue();
expect(matches('/api/v1/users?page=1')).toBeTrue();
expect(matches('/api/v1/v2/users?page=1')).toBeTrue();

expect(matches('/api/other?redirect=/users')).toBeFalse();
expect(matches('/api/users/profile?page=1')).toBeFalse();
});

it('handles single-character wildcard (?) without matching the query delimiter', async () => {
const matches = await createGlobMatcher('/api/v?');

expect(matches('/api/v1')).toBeTrue();
expect(matches('/api/v1?a=1')).toBeTrue();

expect(matches('/api/v')).toBeFalse();
expect(matches('/api/v?')).toBeFalse();
expect(matches('/api/v?a=1')).toBeFalse();
expect(matches('/api/v12?a=1')).toBeFalse();
});

it('handles negated globs (!) with and without query strings', async () => {
const matches = await createGlobMatcher('!/api/public/**');

expect(matches('/api/public')).toBeFalse();
expect(matches('/api/public?foo=1')).toBeFalse();
expect(matches('/api/public/assets?foo=1')).toBeFalse();

expect(matches('/api/private')).toBeTrue();
expect(matches('/api/private?foo=1')).toBeTrue();
});

it('handles brace expansion and extglobs with query strings', async () => {
const matchesBraces = await createGlobMatcher('/api/{auth,users}/**');
expect(matchesBraces('/api/auth?q=1')).toBeTrue();
expect(matchesBraces('/api/users/list?q=1')).toBeTrue();
expect(matchesBraces('/api/other?q=1')).toBeFalse();

const matchesExtglob = await createGlobMatcher('/**/!(*.d).ts');
expect(matchesExtglob('/app/main.ts')).toBeTrue();
expect(matchesExtglob('/app/main.ts?extra=.d.ts')).toBeTrue();
expect(matchesExtglob('/app/main.d.ts')).toBeFalse();
expect(matchesExtglob('/app/main.d.ts?extra=.ts')).toBeFalse();
});
});
});
Loading