From c5ed28ee529004577309a3edc1ddceb2dbddea87 Mon Sep 17 00:00:00 2001 From: Tirth Patel Date: Tue, 5 May 2026 01:37:21 -0400 Subject: [PATCH] Replace url-parse with WHATWG URL in core and content --- packages/content/package.json | 4 +- packages/content/src/media/media-api.test.ts | 59 ++++++------ packages/content/src/media/media-api.ts | 90 +++++++++++++++---- packages/core/package.json | 4 +- .../core/src/graphql-request-client.test.ts | 13 +++ packages/core/src/graphql-request-client.ts | 27 ++++-- yarn.lock | 13 +-- 7 files changed, 140 insertions(+), 70 deletions(-) diff --git a/packages/content/package.json b/packages/content/package.json index 596db12303..d1a04bdbb6 100644 --- a/packages/content/package.json +++ b/packages/content/package.json @@ -49,7 +49,6 @@ "@types/proxyquire": "^1.3.31", "@types/sinon": "^17.0.4", "@types/sinon-chai": "^4.0.0", - "@types/url-parse": "1.4.11", "@typescript-eslint/eslint-plugin": "8.39.0", "@typescript-eslint/parser": "8.39.0", "chai": "^4.4.1", @@ -81,8 +80,7 @@ "chalk": "^4.1.2", "debug": "^4.4.0", "glob": "^11.0.2", - "graphql": "^16.11.0", - "url-parse": "^1.5.10" + "graphql": "^16.11.0" }, "description": "", "types": "types/index.d.ts", diff --git a/packages/content/src/media/media-api.test.ts b/packages/content/src/media/media-api.test.ts index efac4cf039..534fa66456 100644 --- a/packages/content/src/media/media-api.test.ts +++ b/packages/content/src/media/media-api.test.ts @@ -3,10 +3,10 @@ // what is `import x = require('x');`? great question: https://github.com/Microsoft/TypeScript/issues/5073 import chai from 'chai'; import chaiString from 'chai-string'; -import URL from 'url-parse'; import { replaceMediaUrlPrefix, getSrcSet, updateImageUrl, getRequiredParams } from './media-api'; const expect = chai.use(chaiString).expect; +const parseUrl = (input: string) => new URL(input, 'https://content-sdk.invalid'); describe('getRequiredParams', () => { it('should return required query string params', () => { @@ -43,48 +43,48 @@ describe('updateImageUrl', () => { it('should override parameters with those provided', () => { const original = 'http://sitecore/-/media/lorem.png?mh=3&mw=4'; const updated = updateImageUrl(original, { mh: '5', mw: '6' }); - const url = URL(updated, true); - expect(url.query.mh).to.equal('5'); - expect(url.query.mw).to.equal('6'); + const url = parseUrl(updated); + expect(url.searchParams.get('mh')).to.equal('5'); + expect(url.searchParams.get('mw')).to.equal('6'); }); it('should remove non-required query parameters not provided', () => { const original = 'http://sitecore/-/media/lorem.png?h=1&w=2&mh=3&mw=4&hash=CC5043DC03C6C27F40EDB08CF84AB8670C05D63D'; const updated = updateImageUrl(original, { mh: '5', mw: '6' }); - const url = URL(updated, true); - expect(url.query.mh).to.equal('5'); - expect(url.query.mw).to.equal('6'); - expect(url.query.h).to.be.undefined; - expect(url.query.w).to.be.undefined; - expect(url.query.hash).to.be.undefined; + const url = parseUrl(updated); + expect(url.searchParams.get('mh')).to.equal('5'); + expect(url.searchParams.get('mw')).to.equal('6'); + expect(url.searchParams.get('h')).to.be.null; + expect(url.searchParams.get('w')).to.be.null; + expect(url.searchParams.get('hash')).to.be.null; }); it('should preserve required query parameters', () => { const original = 'http://sitecore/-/media/lorem.png?rev=100&db=master&la=en&vs=200&ts=foo&mh=3&mw=4'; const updated = updateImageUrl(original, { mh: '5', mw: '6' }); - const url = URL(updated, true); - expect(url.query.mh).to.equal('5'); - expect(url.query.mw).to.equal('6'); - expect(url.query.rev).to.equal('100'); - expect(url.query.db).to.equal('master'); - expect(url.query.la).to.equal('en'); - expect(url.query.vs).to.equal('200'); - expect(url.query.ts).to.equal('foo'); + const url = parseUrl(updated); + expect(url.searchParams.get('mh')).to.equal('5'); + expect(url.searchParams.get('mw')).to.equal('6'); + expect(url.searchParams.get('rev')).to.equal('100'); + expect(url.searchParams.get('db')).to.equal('master'); + expect(url.searchParams.get('la')).to.equal('en'); + expect(url.searchParams.get('vs')).to.equal('200'); + expect(url.searchParams.get('ts')).to.equal('foo'); }); it('should replace /-/media/ with /-/jssmedia/', () => { const original = 'http://sitecore/-/media/lorem/ipsum.jpg'; const updated = updateImageUrl(original, { foo: 'bar' }); - const url = URL(updated); + const url = parseUrl(updated); expect(url.pathname).to.startsWith('/-/jssmedia/'); }); it('should replace /~/media/ with /~/jssmedia/', () => { const original = 'http://sitecore/~/media/lorem/ipsum.jpg'; const updated = updateImageUrl(original, { foo: 'bar' }); - const url = URL(updated); + const url = parseUrl(updated); expect(url.pathname).to.startsWith('/~/jssmedia/'); }); @@ -93,7 +93,7 @@ describe('updateImageUrl', () => { const original = 'http://sitecore/-assets/lorem/ipsum.jpg'; const mediaUrlPrefix = /\/([-~]{1})assets\//i; const updated = updateImageUrl(original, { foo: 'bar' }, mediaUrlPrefix); - const url = URL(updated); + const url = parseUrl(updated); expect(url.pathname).to.startsWith('/-/jssmedia/'); }); @@ -101,7 +101,7 @@ describe('updateImageUrl', () => { const original = 'http://sitecore/~assets/lorem/ipsum.jpg'; const mediaUrlPrefix = /\/([-~]{1})assets\//i; const updated = updateImageUrl(original, { foo: 'bar' }, mediaUrlPrefix); - const url = URL(updated); + const url = parseUrl(updated); expect(url.pathname).to.startsWith('/~/jssmedia/'); }); @@ -109,7 +109,7 @@ describe('updateImageUrl', () => { const original = 'http://sitecore/-/assets/lorem/ipsum.jpg'; const mediaUrlPrefix = /\/([-~]{1})\/assets\//i; const updated = updateImageUrl(original, { foo: 'bar' }, mediaUrlPrefix); - const url = URL(updated); + const url = parseUrl(updated); expect(url.pathname).to.startsWith('/-/jssmedia/'); }); @@ -117,7 +117,7 @@ describe('updateImageUrl', () => { const original = 'http://sitecore/~/assets/lorem/ipsum.jpg'; const mediaUrlPrefix = /\/([-~]{1})\/assets\//i; const updated = updateImageUrl(original, { foo: 'bar' }, mediaUrlPrefix); - const url = URL(updated); + const url = parseUrl(updated); expect(url.pathname).to.startsWith('/~/jssmedia/'); }); }); @@ -127,12 +127,15 @@ describe('updateImageUrl', () => { '/media/lorem/ipsum.jpg?x=valueX&y=value111&rev=109010&db=333&la=444&vs=555&ts=666&unknownParam=54321'; const params = { y: 'valueY', z: 'valueZ' }; const parsed = updateImageUrl(src, params); - const url = URL(parsed, {}, true); + const url = parseUrl(parsed); expect(url.toString()).equal( + 'https://content-sdk.invalid/media/lorem/ipsum.jpg?y=valueY&z=valueZ&rev=109010&db=333&la=444&vs=555&ts=666' + ); + expect(parsed).equal( '/media/lorem/ipsum.jpg?y=valueY&z=valueZ&rev=109010&db=333&la=444&vs=555&ts=666' ); - expect(url.query).deep.equal({ + expect(Object.fromEntries(url.searchParams.entries())).deep.equal({ y: 'valueY', z: 'valueZ', rev: '109010', @@ -211,14 +214,14 @@ describe('getSrcSet', () => { it('should replace /-/media/ with /-/jssmedia/', () => { const original = 'http://sitecore/-/media/lorem/ipsum.jpg'; const updated = replaceMediaUrlPrefix(original); - const url = URL(updated); + const url = parseUrl(updated); expect(url.pathname).to.startsWith('/-/jssmedia/'); }); it('should replace /~/media/ with /~/jssmedia/', () => { const original = 'http://sitecore/~/media/lorem/ipsum.jpg'; const updated = replaceMediaUrlPrefix(original); - const url = URL(updated); + const url = parseUrl(updated); expect(url.pathname).to.startsWith('/~/jssmedia/'); }); }); diff --git a/packages/content/src/media/media-api.ts b/packages/content/src/media/media-api.ts index 038c42072c..6f2e913b1f 100644 --- a/packages/content/src/media/media-api.ts +++ b/packages/content/src/media/media-api.ts @@ -1,7 +1,56 @@ -import URL from 'url-parse'; - // finds the Sitecore media URL prefix const mediaUrlPrefixRegex = /\/([-~]{1})\/media\//i; +const internalUrlBase = 'https://content-sdk.invalid'; +const absoluteUrlRegex = /^[a-zA-Z][a-zA-Z\d+.-]*:/; + +type ParsedMediaUrlKind = 'absolute' | 'protocol-relative' | 'root-relative' | 'relative-path'; + +type ParsedMediaUrl = { + kind: ParsedMediaUrlKind; + url: URL; +}; + +/** + * Parses a media URL while preserving whether the original input was absolute or relative. + * @param {string} input The media URL to parse + * @returns {ParsedMediaUrl} The parsed URL and original input kind + */ +const parseMediaUrl = (input: string): ParsedMediaUrl => { + let kind: ParsedMediaUrlKind; + + if (absoluteUrlRegex.test(input)) { + kind = 'absolute'; + } else if (input.startsWith('//')) { + kind = 'protocol-relative'; + } else if (input.startsWith('/')) { + kind = 'root-relative'; + } else { + kind = 'relative-path'; + } + + return { + kind, + url: kind === 'absolute' ? new URL(input) : new URL(input, internalUrlBase), + }; +}; + +/** + * Formats a parsed media URL back to the same absolute or relative form as the original input. + * @param {ParsedMediaUrl} parsed The parsed media URL details + * @returns {string} The formatted URL + */ +const formatMediaUrl = ({ kind, url }: ParsedMediaUrl): string => { + switch (kind) { + case 'absolute': + return url.toString(); + case 'protocol-relative': + return `//${url.host}${url.pathname}${url.search}${url.hash}`; + case 'root-relative': + return `${url.pathname}${url.search}${url.hash}`; + default: + return `${url.pathname.replace(/^\//, '')}${url.search}${url.hash}`; + } +}; /** * Get required query string params which should be merged with user params @@ -27,15 +76,15 @@ export const replaceMediaUrlPrefix = ( url: string, mediaUrlPrefix: RegExp = mediaUrlPrefixRegex ): string => { - const parsed = URL(url, {}, true); + const parsed = parseMediaUrl(url); - const match = mediaUrlPrefix.exec(parsed.pathname); + const match = mediaUrlPrefix.exec(parsed.url.pathname); if (match && match.length > 1) { // regex will provide us with /-/ or /~/ type - parsed.set('pathname', parsed.pathname.replace(mediaUrlPrefix, `/${match[1]}/jssmedia/`)); + parsed.url.pathname = parsed.url.pathname.replace(mediaUrlPrefix, `/${match[1]}/jssmedia/`); } - return parsed.toString(); + return formatMediaUrl(parsed); }; /** @@ -59,26 +108,31 @@ export const updateImageUrl = ( // if params aren't supplied, no need to run it through Content SDK media handler return url; } - // polyfill node `global` in browser to workaround https://github.com/unshiftio/url-parse/issues/150 - if (typeof window !== 'undefined' && !window.global) { - window.global = {} as typeof globalThis; - } - - const parsed = URL(replaceMediaUrlPrefix(url, mediaUrlPrefix), {}, true); - - const requiredParams = getRequiredParams(parsed.query); + const parsed = parseMediaUrl(replaceMediaUrlPrefix(url, mediaUrlPrefix)); + const requiredParams = getRequiredParams({ + rev: parsed.url.searchParams.get('rev') || undefined, + db: parsed.url.searchParams.get('db') || undefined, + la: parsed.url.searchParams.get('la') || undefined, + vs: parsed.url.searchParams.get('vs') || undefined, + ts: parsed.url.searchParams.get('ts') || undefined, + }); + const query = new URLSearchParams(); - const query = { ...params }; + Object.entries(params).forEach(([key, param]) => { + if (param !== undefined) { + query.set(key, `${param}`); + } + }); Object.entries(requiredParams).forEach(([key, param]) => { if (param) { - query[key] = param; + query.set(key, param); } }); - parsed.set('query', query); + parsed.url.search = query.toString(); - return parsed.toString(); + return formatMediaUrl(parsed); }; /** diff --git a/packages/core/package.json b/packages/core/package.json index d7545a3c8c..c92310fa86 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,7 +45,6 @@ "@types/node": "^24.10.4", "@types/proxyquire": "^1.3.31", "@types/sinon": "^17.0.4", - "@types/url-parse": "1.4.11", "@typescript-eslint/eslint-plugin": "8.39.0", "@typescript-eslint/parser": "8.39.0", "chai": "^4.4.1", @@ -69,8 +68,7 @@ "debug": "^4.4.0", "graphql": "^16.11.0", "graphql-request": "^6.1.0", - "memory-cache": "^0.2.0", - "url-parse": "^1.5.10" + "memory-cache": "^0.2.0" }, "description": "", "types": "types/index.d.ts", diff --git a/packages/core/src/graphql-request-client.test.ts b/packages/core/src/graphql-request-client.test.ts index 18df39a3b0..c1d9d3ab0e 100644 --- a/packages/core/src/graphql-request-client.test.ts +++ b/packages/core/src/graphql-request-client.test.ts @@ -176,6 +176,19 @@ describe('GraphQLRequestClient', () => { } }); + it('should throw error when endpoint is not an absolute url', () => { + const endpoint = '/graphql'; + + try { + new GraphQLRequestClient(endpoint, { debugger: debug.common }); + expect.fail('Expected constructor to throw for a relative endpoint'); + } catch (error) { + expect(error.toString()).to.equal( + `Error: Invalid GraphQL endpoint '${endpoint}'. Verify that appropriate environment variable is set` + ); + } + }); + it('should throw error when request is aborted with default timeout value', async () => { nock('http://csdknextweb') .post('/graphql') diff --git a/packages/core/src/graphql-request-client.ts b/packages/core/src/graphql-request-client.ts index 57bff876a8..52bbbf4c37 100644 --- a/packages/core/src/graphql-request-client.ts +++ b/packages/core/src/graphql-request-client.ts @@ -1,5 +1,4 @@ import { GraphQLClient as Client, ClientError } from 'graphql-request'; -import parse from 'url-parse'; import { DocumentNode } from 'graphql'; import debuggers, { Debugger } from './debug'; import TimeoutPromise from './tools/timeout-promise'; @@ -89,6 +88,26 @@ export type GraphQLRequestClientFactoryConfig = { endpoint: string; } & GraphQLRequestClientConfig; +/** + * Validates the GraphQL endpoint using the WHATWG URL API. + * @param {string} endpoint The GraphQL endpoint to validate + * @throws {Error} when the endpoint is invalid + */ +const validateGraphQLEndpoint = (endpoint: string) => { + const errorMessage = `Invalid GraphQL endpoint '${endpoint}'. Verify that appropriate environment variable is set`; + let parsedEndpoint: URL; + + try { + parsedEndpoint = new URL(endpoint); + } catch { + throw new Error(errorMessage); + } + + if (!parsedEndpoint.hostname) { + throw new Error(errorMessage); + } +}; + /** * A GraphQL client for Sitecore APIs that uses the 'graphql-request' library. * https://github.com/prisma-labs/graphql-request @@ -119,11 +138,7 @@ export class GraphQLRequestClient implements GraphQLClient { this.headers['x-sitecore-contextid'] = clientConfig.contextId; } - if (!endpoint || !parse(endpoint).hostname) { - throw new Error( - `Invalid GraphQL endpoint '${endpoint}'. Verify that appropriate environment variable is set` - ); - } + validateGraphQLEndpoint(endpoint); this.timeout = clientConfig.timeout; this.retries = clientConfig.retries ?? 3; diff --git a/yarn.lock b/yarn.lock index d246b1a6fb..4eafe327a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3603,7 +3603,6 @@ __metadata: "@types/proxyquire": "npm:^1.3.31" "@types/sinon": "npm:^17.0.4" "@types/sinon-chai": "npm:^4.0.0" - "@types/url-parse": "npm:1.4.11" "@typescript-eslint/eslint-plugin": "npm:8.39.0" "@typescript-eslint/parser": "npm:8.39.0" chai: "npm:^4.4.1" @@ -3630,7 +3629,6 @@ __metadata: tslib: "npm:^2.8.1" tsx: "npm:^4.19.4" typescript: "npm:~5.8.3" - url-parse: "npm:^1.5.10" peerDependencies: "@sitecore-content-sdk/events": ^2.1.0-canary.3 languageName: unknown @@ -3649,7 +3647,6 @@ __metadata: "@types/node": "npm:^24.10.4" "@types/proxyquire": "npm:^1.3.31" "@types/sinon": "npm:^17.0.4" - "@types/url-parse": "npm:1.4.11" "@typescript-eslint/eslint-plugin": "npm:8.39.0" "@typescript-eslint/parser": "npm:8.39.0" chai: "npm:^4.4.1" @@ -3672,7 +3669,6 @@ __metadata: sinon: "npm:^20.0.0" tsx: "npm:^4.19.4" typescript: "npm:~5.8.3" - url-parse: "npm:^1.5.10" languageName: unknown linkType: soft @@ -4442,13 +4438,6 @@ __metadata: languageName: node linkType: hard -"@types/url-parse@npm:1.4.11": - version: 1.4.11 - resolution: "@types/url-parse@npm:1.4.11" - checksum: 10/3e289d184b03d0b0203bccdff00efc1388db2ad8bba4af094201bf3ea5d001f36674ce1ee1764b8906b786a2de625dbc5d76b63ac68e2a3383a93acfe49e01b8 - languageName: node - linkType: hard - "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -14954,7 +14943,7 @@ __metadata: languageName: node linkType: hard -"url-parse@npm:^1.5.10, url-parse@npm:^1.5.3": +"url-parse@npm:^1.5.3": version: 1.5.10 resolution: "url-parse@npm:1.5.10" dependencies: