From 6b5fca8af90f2d9b64d007608f88a8f51675f720 Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 29 Aug 2023 21:29:34 -0400 Subject: [PATCH] feat(url): drop whatwg-url whatwg-url parses correctly, but is quite a big dependency. parseUrl() only handles Chrome+Firefox quirks regarding parsing of urls using non-special schemes. --- package-lock.json | 52 ++++++++++--------------- package.json | 2 - src/components/App.vue | 6 ++- src/core/remote/client.ts | 4 +- src/io/amazonS3.ts | 6 +-- src/io/googleCloudStorage.ts | 8 ++-- src/utils/__tests__/url.spec.ts | 15 ++++++++ src/utils/fetch.ts | 4 +- src/utils/index.ts | 4 +- src/utils/url.ts | 67 +++++++++++++++++++++++++++++++++ 10 files changed, 118 insertions(+), 50 deletions(-) create mode 100644 src/utils/__tests__/url.spec.ts create mode 100644 src/utils/url.ts diff --git a/package-lock.json b/package-lock.json index a33df9156..d8c59b134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "vue": "^3.3.4", "vue-toastification": "^2.0.0-rc.5", "vuetify": "^3.1.14", - "whatwg-url": "^12.0.1", "zod": "^3.22.3" }, "devDependencies": { @@ -48,7 +47,6 @@ "@types/mocha": "^9.1.1", "@types/sinon": "^10.0.11", "@types/sinon-chai": "^3.2.8", - "@types/whatwg-url": "^11.0.0", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitejs/plugin-vue": "^4.2.3", @@ -4459,21 +4457,6 @@ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.14.tgz", "integrity": "sha512-5d2RhCard1nQUC3aHcq/gHzWYO6K0WJmAbjO7mQJgCQKtZpgXxv1rOM6O/dBDhDYYVutk1sciOgNSe+5YyfM8A==" }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==", - "dev": true - }, - "node_modules/@types/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-4F6szvZP3FM5HvJAmcInXBfrAhvM4tLIc8MO1nXwabG5TZVOLxVmAXRpICqXYd3lBlomSRGmLCopYV+yTocgpQ==", - "dev": true, - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "node_modules/@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -21478,6 +21461,9 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -21680,6 +21666,9 @@ "version": "12.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "optional": true, + "peer": true, "dependencies": { "tr46": "^4.1.1", "webidl-conversions": "^7.0.0" @@ -21692,6 +21681,9 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "optional": true, + "peer": true, "dependencies": { "punycode": "^2.3.0" }, @@ -25947,21 +25939,6 @@ "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.14.tgz", "integrity": "sha512-5d2RhCard1nQUC3aHcq/gHzWYO6K0WJmAbjO7mQJgCQKtZpgXxv1rOM6O/dBDhDYYVutk1sciOgNSe+5YyfM8A==" }, - "@types/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==", - "dev": true - }, - "@types/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-4F6szvZP3FM5HvJAmcInXBfrAhvM4tLIc8MO1nXwabG5TZVOLxVmAXRpICqXYd3lBlomSRGmLCopYV+yTocgpQ==", - "dev": true, - "requires": { - "@types/webidl-conversions": "*" - } - }, "@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -38758,7 +38735,10 @@ "webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "optional": true, + "peer": true }, "webpack": { "version": "5.75.0", @@ -38905,6 +38885,9 @@ "version": "12.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "optional": true, + "peer": true, "requires": { "tr46": "^4.1.1", "webidl-conversions": "^7.0.0" @@ -38914,6 +38897,9 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "optional": true, + "peer": true, "requires": { "punycode": "^2.3.0" } diff --git a/package.json b/package.json index 2c195a160..85eb107d2 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "vue": "^3.3.4", "vue-toastification": "^2.0.0-rc.5", "vuetify": "^3.1.14", - "whatwg-url": "^12.0.1", "zod": "^3.22.3" }, "devDependencies": { @@ -64,7 +63,6 @@ "@types/mocha": "^9.1.1", "@types/sinon": "^10.0.11", "@types/sinon-chai": "^3.2.8", - "@types/whatwg-url": "^11.0.0", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitejs/plugin-vue": "^4.2.3", diff --git a/src/components/App.vue b/src/components/App.vue index 427b1f836..748022779 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -212,7 +212,6 @@ import { import { storeToRefs } from 'pinia'; import { UrlParams } from '@vueuse/core'; import vtkURLExtract from '@kitware/vtk.js/Common/Core/URLExtract'; -import { URL } from 'whatwg-url'; import { useDisplay } from 'vuetify'; import { basename } from '@/src/utils/path'; @@ -231,6 +230,7 @@ import type { Vector3 } from '@kitware/vtk.js/types'; import { ViewTypes } from '@kitware/vtk.js/Widgets/Core/WidgetManager/Constants'; import WelcomePage from '@/src/components/WelcomePage.vue'; import { useDICOMStore } from '@/src/store/datasets-dicom'; +import { parseUrl } from '@/src/utils/url'; import ToolButton from './ToolButton.vue'; import LayoutGrid from './LayoutGrid.vue'; import ModulePanel from './ModulePanel.vue'; @@ -317,7 +317,9 @@ async function loadRemoteFilesFromURLParams( const sources = urls.map((url, idx) => uriToDataSource( url, - names[idx] || basename(new URL(url, window.location.href).pathname) || url + names[idx] || + basename(parseUrl(url, window.location.href).pathname) || + url ) ); diff --git a/src/core/remote/client.ts b/src/core/remote/client.ts index c65774137..f13babdae 100644 --- a/src/core/remote/client.ts +++ b/src/core/remote/client.ts @@ -12,7 +12,7 @@ import { nanoid } from 'nanoid'; import { Socket, io } from 'socket.io-client'; import { z } from 'zod'; import * as ChunkedParser from '@/src/core/remote/chunkedParser'; -import { URL } from 'whatwg-url'; +import { parseUrl } from '@/src/utils/url'; const CLIENT_ID_SIZE = 24; const RPC_ID_SIZE = 24; @@ -109,7 +109,7 @@ export interface RpcClientOptions { } function justHostUrl(url: string) { - const parts = new URL(url); + const parts = parseUrl(url); parts.pathname = ''; return String(parts); } diff --git a/src/io/amazonS3.ts b/src/io/amazonS3.ts index e92b0f002..1fb19495b 100644 --- a/src/io/amazonS3.ts +++ b/src/io/amazonS3.ts @@ -1,5 +1,5 @@ +import { parseUrl } from '@/src/utils/url'; import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3'; -import { URL } from 'whatwg-url'; /** * Detects `s3://` uri. @@ -7,7 +7,7 @@ import { URL } from 'whatwg-url'; * @returns */ export const isAmazonS3Uri = (uri: string) => - new URL(uri, window.location.origin).protocol === 's3:'; + parseUrl(uri, window.location.origin).protocol === 's3:'; export type ObjectAvailableCallback = (url: string, name: string) => void; @@ -67,7 +67,7 @@ async function fetchObjectsWithPagination( * @returns */ export const extractBucketAndPrefixFromS3Uri = (uri: string) => { - const { hostname: bucket, pathname } = new URL(uri); + const { hostname: bucket, pathname } = parseUrl(uri); // drop the leading forward slash const objectName = pathname.replace(/^\//, ''); return [bucket, objectName] as const; diff --git a/src/io/googleCloudStorage.ts b/src/io/googleCloudStorage.ts index 08639f5b6..66ed16eee 100644 --- a/src/io/googleCloudStorage.ts +++ b/src/io/googleCloudStorage.ts @@ -1,4 +1,4 @@ -import { URL } from 'whatwg-url'; +import { parseUrl } from '@/src/utils/url'; import { fetchJSON } from '../utils/fetch'; export interface GcsObject { @@ -23,7 +23,7 @@ interface GcsObjectListResult { * @returns */ export const isGoogleCloudStorageUri = (uri: string) => - new URL(uri, window.location.origin).protocol === 'gs:'; + parseUrl(uri, window.location.origin).protocol === 'gs:'; /** * Extracts bucket and prefix from `gs://` URIs @@ -31,7 +31,7 @@ export const isGoogleCloudStorageUri = (uri: string) => * @returns */ export const extractBucketAndPrefixFromGsUri = (uri: string) => { - const { hostname: bucket, pathname } = new URL(uri); + const { hostname: bucket, pathname } = parseUrl(uri); // drop the leading forward slash const objectName = pathname.replace(/^\//, ''); return [bucket, objectName] as const; @@ -50,7 +50,7 @@ async function fetchObjectsWithPagination( const objects: GcsObject[] = []; const paginate = async (nextToken?: string) => { - const url = new URL(getObjectEndpoint(bucket)); + const url = parseUrl(getObjectEndpoint(bucket)); url.searchParams.append('prefix', prefix); url.searchParams.append('maxResults', '1000'); if (nextToken) { diff --git a/src/utils/__tests__/url.spec.ts b/src/utils/__tests__/url.spec.ts new file mode 100644 index 000000000..9aeb12f54 --- /dev/null +++ b/src/utils/__tests__/url.spec.ts @@ -0,0 +1,15 @@ +import { parseUrl } from '@/src/utils/url'; +import { describe, expect, it } from 'vitest'; + +describe('utils/url', () => { + describe('parseUrl', () => { + it('should parse a URL', () => { + expect(parseUrl('https://example.com/path').pathname).to.equal('/path'); + expect(parseUrl('gs://bucket/').protocol).to.equal('gs:'); + expect(parseUrl('gs://bucket/path/object').pathname).to.equal( + '/path/object' + ); + expect(parseUrl('path/object', 'gs://bucket').protocol).to.equal('gs:'); + }); + }); +}); diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts index f1c16533c..bc8659ae3 100644 --- a/src/utils/fetch.ts +++ b/src/utils/fetch.ts @@ -1,5 +1,5 @@ +import { parseUrl } from '@/src/utils/url'; import { Awaitable } from '@vueuse/core'; -import { URL } from 'whatwg-url'; /** * Percent is in [0, 1]. If it's Infinity, then the progress is indeterminate. @@ -25,7 +25,7 @@ interface URLHandler { */ const HTTPHandler: URLHandler = { testURL: (url) => { - const { protocol } = new URL(url, window.location.href); + const { protocol } = parseUrl(url, window.location.href); return protocol === 'http:' || protocol === 'https:'; }, fetchURL: async (url, options = {}) => { diff --git a/src/utils/index.ts b/src/utils/index.ts index 4c509e24c..914334605 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,6 @@ -import { URL } from 'whatwg-url'; import { z } from 'zod'; import { TypedArray } from 'itk-wasm'; +import { parseUrl } from '@/src/utils/url'; import { EPSILON } from '../constants'; import { Maybe } from '../types'; @@ -203,7 +203,7 @@ export function wrapInArray(maybeArray: T | T[]): T[] { * Extracts the basename from a URL. */ export function getURLBasename(url: string) { - return new URL(url, window.location.href).pathname.split('/').at(-1) ?? ''; + return parseUrl(url, window.location.href).pathname.split('/').at(-1) ?? ''; } // from https://stackoverflow.com/a/18650828 diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 000000000..3a675c565 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,67 @@ +const SPECIAL_SCHEME_RE = /^(https?|ftp|file|wss?):$/; + +/** + * Represents a URL object with relaxed writing semantics. + * + * The protocol field is now unconditionally writeable, + * regardless of whether the protocol is special. + */ +export class WriteableURL extends URL { + _proto: string; + + constructor(url: string, base?: string) { + super(url, base); + this._proto = this.protocol; + } + + set protocol(proto: string) { + this._proto = proto; + } + + get protocol() { + return this._proto; + } + + private replaceProtocol(url: string) { + const re = new RegExp(`^${super.protocol}`); + return url.replace(re, this._proto); + } + + get origin() { + return this.replaceProtocol(super.origin); + } + + get href() { + return this.replaceProtocol(super.href); + } + + toString() { + return this.replaceProtocol(super.toString()); + } +} + +/** + * Parses a URL. + * + * Chrome, Firefox, and Safari parse URLS differently for + * non-special schemes. parseUrl's goal is to have uniform + * output across browsers for non-special schemes. + * + * @param url + * @param base + * @returns + */ +export function parseUrl(url: string, base?: string) { + let parsed = new URL(url, base); + if (SPECIAL_SCHEME_RE.test(parsed.protocol)) { + return parsed; + } + + // replace the scheme with a special scheme to get uniform parsing. + const proto = parsed.protocol; + const re = new RegExp(`^${proto}`); + parsed = new WriteableURL(url.replace(re, 'ws:'), base?.replace(re, 'ws:')); + // This line doesn't work with the standard URL API. + parsed.protocol = proto; + return parsed; +}