286 lines
9.4 KiB
TypeScript
286 lines
9.4 KiB
TypeScript
/**
|
|
* Copyright (c) Microsoft Corporation.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { isString } from './stringUtils';
|
|
|
|
export function isHttpUrl(url: string, base?: string): boolean {
|
|
try {
|
|
return ['http:', 'https:'].includes(new URL(url, base).protocol);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
|
|
const escapedChars = new Set(['$', '^', '+', '.', '*', '(', ')', '|', '\\', '?', '{', '}', '[', ']']);
|
|
|
|
export function globToRegexPattern(glob: string): string {
|
|
const tokens = ['^'];
|
|
let inGroup = false;
|
|
for (let i = 0; i < glob.length; ++i) {
|
|
const c = glob[i];
|
|
if (c === '\\' && i + 1 < glob.length) {
|
|
const char = glob[++i];
|
|
tokens.push(escapedChars.has(char) ? '\\' + char : char);
|
|
continue;
|
|
}
|
|
if (c === '*') {
|
|
const charBefore = glob[i - 1];
|
|
let starCount = 1;
|
|
while (glob[i + 1] === '*') {
|
|
starCount++;
|
|
i++;
|
|
}
|
|
if (starCount > 1) {
|
|
const charAfter = glob[i + 1];
|
|
// Match either /..something../ or /.
|
|
if (charAfter === '/') {
|
|
if (charBefore === '/')
|
|
tokens.push('((.+/)|)');
|
|
else
|
|
tokens.push('(.*/)');
|
|
++i;
|
|
} else {
|
|
tokens.push('(.*)');
|
|
}
|
|
} else {
|
|
tokens.push('([^/]*)');
|
|
}
|
|
continue;
|
|
}
|
|
|
|
switch (c) {
|
|
case '{':
|
|
if (inGroup)
|
|
throw new Error(`Invalid glob pattern ${JSON.stringify(glob)}: nested '{' is not supported`);
|
|
inGroup = true;
|
|
tokens.push('(');
|
|
break;
|
|
case '}':
|
|
if (!inGroup)
|
|
throw new Error(`Invalid glob pattern ${JSON.stringify(glob)}: unmatched '}'`);
|
|
inGroup = false;
|
|
tokens.push(')');
|
|
break;
|
|
case ',':
|
|
if (inGroup) {
|
|
tokens.push('|');
|
|
break;
|
|
}
|
|
tokens.push('\\' + c);
|
|
break;
|
|
default:
|
|
tokens.push(escapedChars.has(c) ? '\\' + c : c);
|
|
}
|
|
}
|
|
if (inGroup)
|
|
throw new Error(`Invalid glob pattern ${JSON.stringify(glob)}: unmatched '{'`);
|
|
tokens.push('$');
|
|
return tokens.join('');
|
|
}
|
|
|
|
function isRegExp(obj: any): obj is RegExp {
|
|
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
|
|
}
|
|
|
|
export type URLMatch = string | RegExp | ((url: URL) => boolean) | URLPattern;
|
|
// URLPattern is not in @types/node@18, so we polyfill it ourselves
|
|
export type URLPattern = {
|
|
test(input: string | URL): boolean;
|
|
hash: string;
|
|
hostname: string;
|
|
password: string;
|
|
pathname: string;
|
|
port: string;
|
|
protocol: string;
|
|
search: string;
|
|
username: string;
|
|
};
|
|
|
|
// @ts-ignore URLPattern is not in @types/node yet
|
|
// eslint-disable-next-line no-restricted-globals
|
|
export const isURLPattern = (v: unknown): v is URLPattern => typeof globalThis.URLPattern === 'function' && v instanceof globalThis.URLPattern;
|
|
|
|
export function serializeURLPattern(v: URLPattern) {
|
|
return {
|
|
hash: v.hash,
|
|
hostname: v.hostname,
|
|
password: v.password,
|
|
pathname: v.pathname,
|
|
port: v.port,
|
|
protocol: v.protocol,
|
|
search: v.search,
|
|
username: v.username,
|
|
};
|
|
}
|
|
|
|
export type SerializedURLMatch = { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType<typeof serializeURLPattern> };
|
|
|
|
export function serializeURLMatch(match: URLMatch): SerializedURLMatch | undefined {
|
|
if (isString(match))
|
|
return { glob: match };
|
|
if (isRegExp(match))
|
|
return { regexSource: match.source, regexFlags: match.flags };
|
|
if (isURLPattern(match))
|
|
return { urlPattern: serializeURLPattern(match) };
|
|
// Functions cannot be serialized
|
|
return undefined;
|
|
}
|
|
|
|
function deserializeURLPattern(v: ReturnType<typeof serializeURLPattern>): URLPattern | ((url: URL) => boolean) {
|
|
// Client is on Node 24+ and can use URLPattern, Server is not. Let's match all URLs on the server, they'll be filtered again on the client.
|
|
// @ts-ignore URLPattern is not in @types/node yet
|
|
// eslint-disable-next-line no-restricted-globals
|
|
if (typeof globalThis.URLPattern !== 'function')
|
|
return () => true;
|
|
|
|
// @ts-ignore URLPattern is not in @types/node yet
|
|
// eslint-disable-next-line no-restricted-globals
|
|
return new globalThis.URLPattern({
|
|
hash: v.hash,
|
|
hostname: v.hostname,
|
|
password: v.password,
|
|
pathname: v.pathname,
|
|
port: v.port,
|
|
protocol: v.protocol,
|
|
search: v.search,
|
|
username: v.username,
|
|
});
|
|
}
|
|
|
|
export function deserializeURLMatch(match: { glob?: string, regexSource?: string, regexFlags?: string, urlPattern?: ReturnType<typeof serializeURLPattern> }): URLMatch {
|
|
if (match.regexSource)
|
|
return new RegExp(match.regexSource, match.regexFlags);
|
|
if (match.urlPattern)
|
|
return deserializeURLPattern(match.urlPattern);
|
|
return match.glob!;
|
|
}
|
|
|
|
export function urlMatchesEqual(match1: URLMatch, match2: URLMatch) {
|
|
if (isRegExp(match1) && isRegExp(match2))
|
|
return match1.source === match2.source && match1.flags === match2.flags;
|
|
return match1 === match2;
|
|
}
|
|
|
|
export function urlMatches(baseURL: string | undefined, urlString: string, match: URLMatch | undefined, webSocketUrl?: boolean): boolean {
|
|
if (match === undefined || match === '')
|
|
return true;
|
|
if (isString(match))
|
|
match = new RegExp(resolveGlobToRegexPattern(baseURL, match, webSocketUrl));
|
|
if (isRegExp(match)) {
|
|
const r = match.test(urlString);
|
|
return r;
|
|
}
|
|
const url = parseURL(urlString);
|
|
if (!url)
|
|
return false;
|
|
if (isURLPattern(match))
|
|
return match.test(url.href);
|
|
if (typeof match !== 'function')
|
|
throw new Error('url parameter should be string, RegExp, URLPattern or function');
|
|
return match(url);
|
|
}
|
|
|
|
export function resolveGlobToRegexPattern(baseURL: string | undefined, glob: string, webSocketUrl?: boolean): string {
|
|
if (webSocketUrl)
|
|
baseURL = toWebSocketBaseUrl(baseURL);
|
|
glob = resolveGlobBase(baseURL, glob);
|
|
return globToRegexPattern(glob);
|
|
}
|
|
|
|
function toWebSocketBaseUrl(baseURL: string | undefined) {
|
|
// Allow http(s) baseURL to match ws(s) urls.
|
|
if (baseURL && /^https?:\/\//.test(baseURL))
|
|
baseURL = baseURL.replace(/^http/, 'ws');
|
|
return baseURL;
|
|
}
|
|
|
|
function resolveGlobBase(baseURL: string | undefined, match: string): string {
|
|
if (!match.startsWith('*')) {
|
|
const tokenMap = new Map<string, string>();
|
|
function mapToken(original: string, replacement: string) {
|
|
if (original.length === 0)
|
|
return '';
|
|
tokenMap.set(replacement, original);
|
|
return replacement;
|
|
}
|
|
// Escaped `\\?` behaves the same as `?` in our glob patterns.
|
|
match = match.replaceAll(/\\\\\?/g, '?');
|
|
// Special case about: URLs as they are not relative to baseURL
|
|
if (match.startsWith('about:') || match.startsWith('data:')
|
|
|| match.startsWith('chrome:') || match.startsWith('edge:')
|
|
|| match.startsWith('file:'))
|
|
return match;
|
|
// Glob symbols may be escaped in the URL and some of them such as ? affect resolution,
|
|
// so we replace them with safe components first.
|
|
const relativePath = match.split('/').map((token, index) => {
|
|
if (token === '.' || token === '..' || token === '')
|
|
return token;
|
|
// Handle special case of http*://, note that the new schema has to be
|
|
// a web schema so that slashes are properly inserted after domain.
|
|
if (index === 0 && token.endsWith(':')) {
|
|
// Replace any pattern with http:
|
|
if (token.indexOf('*') !== -1 || token.indexOf('{') !== -1)
|
|
return mapToken(token, 'http:');
|
|
// Preserve explicit schema as is as it may affect trailing slashes after domain.
|
|
return token;
|
|
}
|
|
const questionIndex = token.indexOf('?');
|
|
if (questionIndex === -1)
|
|
return mapToken(token, `$_${index}_$`);
|
|
const newPrefix = mapToken(token.substring(0, questionIndex), `$_${index}_$`);
|
|
const newSuffix = mapToken(token.substring(questionIndex), `?$_${index}_$`);
|
|
return newPrefix + newSuffix;
|
|
}).join('/');
|
|
const result = resolveBaseURL(baseURL, relativePath);
|
|
let resolved = result.resolved;
|
|
for (const [token, original] of tokenMap) {
|
|
const normalize = result.caseInsensitivePart?.includes(token);
|
|
resolved = resolved.replace(token, normalize ? original.toLowerCase() : original);
|
|
}
|
|
match = resolved;
|
|
}
|
|
return match;
|
|
}
|
|
|
|
function parseURL(url: string): URL | null {
|
|
try {
|
|
return new URL(url);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function constructURLBasedOnBaseURL(baseURL: string | undefined, givenURL: string): string {
|
|
try {
|
|
return resolveBaseURL(baseURL, givenURL).resolved;
|
|
} catch (e) {
|
|
return givenURL;
|
|
}
|
|
}
|
|
|
|
function resolveBaseURL(baseURL: string | undefined, givenURL: string) {
|
|
try {
|
|
const url = new URL(givenURL, baseURL);
|
|
const resolved = url.toString();
|
|
// Schema and domain are case-insensitive.
|
|
const caseInsensitivePrefix = url.origin;
|
|
return { resolved, caseInsensitivePart: caseInsensitivePrefix };
|
|
} catch (e) {
|
|
return { resolved: givenURL };
|
|
}
|
|
}
|