better-ship 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-support/floor.d.ts +43 -0
- package/dist/browser-support/floor.d.ts.map +1 -0
- package/dist/browser-support/floor.js +3 -0
- package/dist/browser-support/oxlint.d.ts +6 -0
- package/dist/browser-support/oxlint.d.ts.map +1 -0
- package/dist/browser-support/oxlint.js +175 -0
- package/dist/browser-support/oxlint.js.map +1 -0
- package/dist/browser-support/polyfills.d.ts +10 -0
- package/dist/browser-support/polyfills.d.ts.map +1 -0
- package/dist/browser-support/polyfills.js +3 -0
- package/dist/browser-support/update-screen.d.ts +51 -0
- package/dist/browser-support/update-screen.d.ts.map +1 -0
- package/dist/browser-support/update-screen.js +107 -0
- package/dist/browser-support/update-screen.js.map +1 -0
- package/dist/floor-Cuw8EIhg.js +146 -0
- package/dist/floor-Cuw8EIhg.js.map +1 -0
- package/dist/polyfills-DEQFkqRI.js +28 -0
- package/dist/polyfills-DEQFkqRI.js.map +1 -0
- package/dist/sentry/client.d.ts +21 -0
- package/dist/sentry/client.d.ts.map +1 -1
- package/dist/sentry/client.js +23 -1
- package/dist/sentry/client.js.map +1 -1
- package/package.json +21 -1
- package/src/infrastructure/browser-support/.browserslistrc +5 -0
- package/src/infrastructure/browser-support/browser-floor.error.ts +31 -0
- package/src/infrastructure/browser-support/floor.ts +145 -0
- package/src/infrastructure/browser-support/oxlint/index.ts +13 -0
- package/src/infrastructure/browser-support/oxlint/no-unsupported-api.ts +219 -0
- package/src/infrastructure/browser-support/polyfills.ts +25 -0
- package/src/infrastructure/browser-support/update-screen.ts +142 -0
- package/src/infrastructure/sentry/client.ts +41 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/infrastructure/browser-support/polyfills.ts
|
|
2
|
+
/** Platform APIs `installPolyfills` provides. The lint rule allows these names. */
|
|
3
|
+
const POLYFILLED_APIS = ["URL.parse"];
|
|
4
|
+
/**
|
|
5
|
+
* Install `URL.parse` when the host has none.
|
|
6
|
+
* A native `URL.parse` on `URL` stays in place.
|
|
7
|
+
*/
|
|
8
|
+
function installPolyfills() {
|
|
9
|
+
if (Object.getOwnPropertyDescriptor(URL, "parse") !== void 0) return;
|
|
10
|
+
Object.defineProperty(URL, "parse", {
|
|
11
|
+
configurable: true,
|
|
12
|
+
writable: true,
|
|
13
|
+
value: parseUrl
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function parseUrl(url, base) {
|
|
17
|
+
try {
|
|
18
|
+
if (base === void 0) return new URL(url);
|
|
19
|
+
return new URL(url, base);
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
if (cause instanceof TypeError) return null;
|
|
22
|
+
throw cause;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { installPolyfills as n, POLYFILLED_APIS as t };
|
|
28
|
+
//# sourceMappingURL=polyfills-DEQFkqRI.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"polyfills-DEQFkqRI.js","names":[],"sources":["../src/infrastructure/browser-support/polyfills.ts"],"sourcesContent":["/** Platform APIs `installPolyfills` provides. The lint rule allows these names. */\nexport const POLYFILLED_APIS = ['URL.parse'] as const\n\n/**\n * Install `URL.parse` when the host has none.\n * A native `URL.parse` on `URL` stays in place.\n */\nexport function installPolyfills(): void {\n if (Object.getOwnPropertyDescriptor(URL, 'parse') !== undefined) return\n Object.defineProperty(URL, 'parse', {\n configurable: true,\n writable: true,\n value: parseUrl,\n })\n}\n\nfunction parseUrl(url: string | URL, base?: string | URL): URL | null {\n try {\n if (base === undefined) return new URL(url)\n return new URL(url, base)\n } catch (cause) {\n if (cause instanceof TypeError) return null\n throw cause\n }\n}\n"],"mappings":";;AACA,MAAa,kBAAkB,CAAC,WAAW;;;;;AAM3C,SAAgB,mBAAyB;CACvC,IAAI,OAAO,yBAAyB,KAAK,OAAO,MAAM,QAAW;CACjE,OAAO,eAAe,KAAK,SAAS;EAClC,cAAc;EACd,UAAU;EACV,OAAO;CACT,CAAC;AACH;AAEA,SAAS,SAAS,KAAmB,MAAiC;CACpE,IAAI;EACF,IAAI,SAAS,QAAW,OAAO,IAAI,IAAI,GAAG;EAC1C,OAAO,IAAI,IAAI,KAAK,IAAI;CAC1B,SAAS,OAAO;EACd,IAAI,iBAAiB,WAAW,OAAO;EACvC,MAAM;CACR;AACF"}
|
package/dist/sentry/client.d.ts
CHANGED
|
@@ -11,6 +11,27 @@ export type SentryClientOptions = Sentry.BrowserOptions & {
|
|
|
11
11
|
export declare function createSentryClientOptions(options: SentryClientOptions): Sentry.BrowserOptions;
|
|
12
12
|
/** Forward logged browser errors once, excluding TanStack navigation control flow. */
|
|
13
13
|
export declare function createSentryLoggerErrorHook(): LoggerErrorHook;
|
|
14
|
+
/** The error value as Sentry's React handler receives it. */
|
|
15
|
+
type ReactRootError = Parameters<ReturnType<typeof Sentry.reactErrorHandler>>[0];
|
|
16
|
+
/** The details React passes to a root error option; React may omit the component stack. */
|
|
17
|
+
export type ReactRootErrorInfo = {
|
|
18
|
+
readonly componentStack?: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
/** A `hydrateRoot` error option in React's own shape. */
|
|
21
|
+
type ReactRootErrorHandler = (error: ReactRootError, errorInfo: ReactRootErrorInfo) => void;
|
|
22
|
+
/** The `hydrateRoot` error options; each one replaces React's console default. */
|
|
23
|
+
export type SentryReactRootOptions = {
|
|
24
|
+
readonly onCaughtError: ReactRootErrorHandler;
|
|
25
|
+
readonly onUncaughtError: ReactRootErrorHandler;
|
|
26
|
+
readonly onRecoverableError: ReactRootErrorHandler;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Report React errors to Sentry from `hydrateRoot`, as Sentry's React 19 setup does.
|
|
30
|
+
* Covers errors that a boundary catches, errors that no boundary catches, and hydration
|
|
31
|
+
* errors that React recovers from. TanStack redirects and not-found control flow are not
|
|
32
|
+
* reported. Each error still goes to the console, as with React's defaults.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createSentryReactRootOptions(): SentryReactRootOptions;
|
|
14
35
|
/** Initialize once in the browser entrypoint before hydration; server imports have no side effects. */
|
|
15
36
|
export declare function initializeSentry(options: SentryClientOptions): ReturnType<typeof Sentry.init>;
|
|
16
37
|
/** Attach tracing once after creating the browser router, as Porte does in getRouter. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","names":[],"sources":["../../src/infrastructure/sentry/client.ts"],"mappings":";;;;YAMY,sBAAsB,OAAO;WAC9B;WACA;WACA;;;wBAIK,0BAA0B,SAAS,sBAAsB,OAAO;;wBAWhE,+BAA+B;;
|
|
1
|
+
{"version":3,"file":"client.d.ts","names":[],"sources":["../../src/infrastructure/sentry/client.ts"],"mappings":";;;;YAMY,sBAAsB,OAAO;WAC9B;WACA;WACA;;;wBAIK,0BAA0B,SAAS,sBAAsB,OAAO;;wBAWhE,+BAA+B;;KAY1C,iBAAiB,WAAW,kBAAkB,OAAO;;YAG9C;WAAgC;;;KAGvC,yBAAyB,OAAO,gBAAgB,WAAW;;YAGpD;WACD,eAAe;WACf,iBAAiB;WACjB,oBAAoB;;;;;;;;wBASf,gCAAgC;;wBAoBhC,iBAAiB,SAAS,sBAAsB,kBAAkB,OAAO;;wBAOzE,uBACd,QAAQ,kBAAkB,OAAO;;qBAOtB,sBAAsB,OAAO"}
|
package/dist/sentry/client.js
CHANGED
|
@@ -24,6 +24,28 @@ function createSentryLoggerErrorHook() {
|
|
|
24
24
|
Sentry.captureException(error, { extra: attributes });
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Report React errors to Sentry from `hydrateRoot`, as Sentry's React 19 setup does.
|
|
29
|
+
* Covers errors that a boundary catches, errors that no boundary catches, and hydration
|
|
30
|
+
* errors that React recovers from. TanStack redirects and not-found control flow are not
|
|
31
|
+
* reported. Each error still goes to the console, as with React's defaults.
|
|
32
|
+
*/
|
|
33
|
+
function createSentryReactRootOptions() {
|
|
34
|
+
const handler = Sentry.reactErrorHandler((error, errorInfo) => {
|
|
35
|
+
console.error(error, errorInfo.componentStack);
|
|
36
|
+
});
|
|
37
|
+
const report = (error, { componentStack }) => {
|
|
38
|
+
handler(error, { componentStack: componentStack ?? null });
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
onCaughtError: (error, errorInfo) => {
|
|
42
|
+
if (isNotFound(error) || isRedirect(error)) return;
|
|
43
|
+
report(error, errorInfo);
|
|
44
|
+
},
|
|
45
|
+
onUncaughtError: report,
|
|
46
|
+
onRecoverableError: report
|
|
47
|
+
};
|
|
48
|
+
}
|
|
27
49
|
/** Initialize once in the browser entrypoint before hydration; server imports have no side effects. */
|
|
28
50
|
function initializeSentry(options) {
|
|
29
51
|
const client = Sentry.init(createSentryClientOptions(options));
|
|
@@ -39,5 +61,5 @@ function instrumentSentryRouter(router) {
|
|
|
39
61
|
const ErrorBoundary = Sentry.ErrorBoundary;
|
|
40
62
|
|
|
41
63
|
//#endregion
|
|
42
|
-
export { ErrorBoundary, Sentry, createSentryClientOptions, createSentryLoggerErrorHook, initializeSentry, instrumentSentryRouter };
|
|
64
|
+
export { ErrorBoundary, Sentry, createSentryClientOptions, createSentryLoggerErrorHook, createSentryReactRootOptions, initializeSentry, instrumentSentryRouter };
|
|
43
65
|
//# sourceMappingURL=client.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":[],"sources":["../../src/infrastructure/sentry/client.ts"],"sourcesContent":["import * as Sentry from '@sentry/tanstackstart-react'\nimport { isNotFound, isRedirect } from '@tanstack/react-router'\n\nimport { setLoggerErrorHook, type LoggerErrorHook } from '@/core'\n\n/** Application settings use Sentry's names; enablement and environment belong to the application. */\nexport type SentryClientOptions = Sentry.BrowserOptions & {\n readonly dsn: string\n readonly environment: string\n readonly enabled: boolean\n}\n\n/** Filter known DOM errors and use SDK collection defaults; application options take precedence. */\nexport function createSentryClientOptions(options: SentryClientOptions): Sentry.BrowserOptions {\n return {\n ignoreErrors: [\n /^NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node\\.$/,\n /^NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node\\.$/,\n ],\n ...options,\n }\n}\n\n/** Forward logged browser errors once, excluding TanStack navigation control flow. */\nexport function createSentryLoggerErrorHook(): LoggerErrorHook {\n return ({ error, userId, attributes }) => {\n if (isNotFound(error) || isRedirect(error)) return\n if (userId !== undefined) {\n Sentry.captureException(error, { extra: attributes, user: { id: userId } })\n return\n }\n Sentry.captureException(error, { extra: attributes })\n }\n}\n\n/** Initialize once in the browser entrypoint before hydration; server imports have no side effects. */\nexport function initializeSentry(options: SentryClientOptions): ReturnType<typeof Sentry.init> {\n const client = Sentry.init(createSentryClientOptions(options))\n setLoggerErrorHook(createSentryLoggerErrorHook())\n return client\n}\n\n/** Attach tracing once after creating the browser router, as Porte does in getRouter. */\nexport function instrumentSentryRouter(\n router: Parameters<typeof Sentry.tanstackRouterBrowserTracingIntegration>[0],\n): void {\n if (router.isServer) return\n Sentry.addIntegration(Sentry.tanstackRouterBrowserTracingIntegration(router))\n}\n\n/** The SDK error boundary; the application supplies its fallback component. */\nexport const ErrorBoundary: typeof Sentry.ErrorBoundary = Sentry.ErrorBoundary\n\nexport { Sentry }\n"],"mappings":";;;;;;AAaA,SAAgB,0BAA0B,SAAqD;CAC7F,OAAO;EACL,cAAc,CACZ,oHACA,iJACF;EACA,GAAG;CACL;AACF;;AAGA,SAAgB,8BAA+C;CAC7D,QAAQ,EAAE,OAAO,QAAQ,iBAAiB;EACxC,IAAI,WAAW,KAAK,KAAK,WAAW,KAAK,GAAG;EAC5C,IAAI,WAAW,QAAW;GACxB,OAAO,iBAAiB,OAAO;IAAE,OAAO;IAAY,MAAM,EAAE,IAAI,OAAO;GAAE,CAAC;GAC1E;EACF;EACA,OAAO,iBAAiB,OAAO,EAAE,OAAO,WAAW,CAAC;CACtD;AACF;;AAGA,SAAgB,iBAAiB,SAA8D;CAC7F,MAAM,SAAS,OAAO,KAAK,0BAA0B,OAAO,CAAC;CAC7D,mBAAmB,4BAA4B,CAAC;CAChD,OAAO;AACT;;AAGA,SAAgB,uBACd,QACM;CACN,IAAI,OAAO,UAAU;CACrB,OAAO,eAAe,OAAO,wCAAwC,MAAM,CAAC;AAC9E;;AAGA,MAAa,gBAA6C,OAAO"}
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../../src/infrastructure/sentry/client.ts"],"sourcesContent":["import * as Sentry from '@sentry/tanstackstart-react'\nimport { isNotFound, isRedirect } from '@tanstack/react-router'\n\nimport { setLoggerErrorHook, type LoggerErrorHook } from '@/core'\n\n/** Application settings use Sentry's names; enablement and environment belong to the application. */\nexport type SentryClientOptions = Sentry.BrowserOptions & {\n readonly dsn: string\n readonly environment: string\n readonly enabled: boolean\n}\n\n/** Filter known DOM errors and use SDK collection defaults; application options take precedence. */\nexport function createSentryClientOptions(options: SentryClientOptions): Sentry.BrowserOptions {\n return {\n ignoreErrors: [\n /^NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node\\.$/,\n /^NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node\\.$/,\n ],\n ...options,\n }\n}\n\n/** Forward logged browser errors once, excluding TanStack navigation control flow. */\nexport function createSentryLoggerErrorHook(): LoggerErrorHook {\n return ({ error, userId, attributes }) => {\n if (isNotFound(error) || isRedirect(error)) return\n if (userId !== undefined) {\n Sentry.captureException(error, { extra: attributes, user: { id: userId } })\n return\n }\n Sentry.captureException(error, { extra: attributes })\n }\n}\n\n/** The error value as Sentry's React handler receives it. */\ntype ReactRootError = Parameters<ReturnType<typeof Sentry.reactErrorHandler>>[0]\n\n/** The details React passes to a root error option; React may omit the component stack. */\nexport type ReactRootErrorInfo = { readonly componentStack?: string | undefined }\n\n/** A `hydrateRoot` error option in React's own shape. */\ntype ReactRootErrorHandler = (error: ReactRootError, errorInfo: ReactRootErrorInfo) => void\n\n/** The `hydrateRoot` error options; each one replaces React's console default. */\nexport type SentryReactRootOptions = {\n readonly onCaughtError: ReactRootErrorHandler\n readonly onUncaughtError: ReactRootErrorHandler\n readonly onRecoverableError: ReactRootErrorHandler\n}\n\n/**\n * Report React errors to Sentry from `hydrateRoot`, as Sentry's React 19 setup does.\n * Covers errors that a boundary catches, errors that no boundary catches, and hydration\n * errors that React recovers from. TanStack redirects and not-found control flow are not\n * reported. Each error still goes to the console, as with React's defaults.\n */\nexport function createSentryReactRootOptions(): SentryReactRootOptions {\n const handler = Sentry.reactErrorHandler((error, errorInfo) => {\n console.error(error, errorInfo.componentStack)\n })\n // Sentry's handler takes `null` for a missing component stack.\n const report: ReactRootErrorHandler = (error, { componentStack }) => {\n handler(error, { componentStack: componentStack ?? null })\n }\n return {\n // TanStack Router renders not-found through a React error boundary, so each 404 arrives here.\n onCaughtError: (error, errorInfo) => {\n if (isNotFound(error) || isRedirect(error)) return\n report(error, errorInfo)\n },\n onUncaughtError: report,\n onRecoverableError: report,\n }\n}\n\n/** Initialize once in the browser entrypoint before hydration; server imports have no side effects. */\nexport function initializeSentry(options: SentryClientOptions): ReturnType<typeof Sentry.init> {\n const client = Sentry.init(createSentryClientOptions(options))\n setLoggerErrorHook(createSentryLoggerErrorHook())\n return client\n}\n\n/** Attach tracing once after creating the browser router, as Porte does in getRouter. */\nexport function instrumentSentryRouter(\n router: Parameters<typeof Sentry.tanstackRouterBrowserTracingIntegration>[0],\n): void {\n if (router.isServer) return\n Sentry.addIntegration(Sentry.tanstackRouterBrowserTracingIntegration(router))\n}\n\n/** The SDK error boundary; the application supplies its fallback component. */\nexport const ErrorBoundary: typeof Sentry.ErrorBoundary = Sentry.ErrorBoundary\n\nexport { Sentry }\n"],"mappings":";;;;;;AAaA,SAAgB,0BAA0B,SAAqD;CAC7F,OAAO;EACL,cAAc,CACZ,oHACA,iJACF;EACA,GAAG;CACL;AACF;;AAGA,SAAgB,8BAA+C;CAC7D,QAAQ,EAAE,OAAO,QAAQ,iBAAiB;EACxC,IAAI,WAAW,KAAK,KAAK,WAAW,KAAK,GAAG;EAC5C,IAAI,WAAW,QAAW;GACxB,OAAO,iBAAiB,OAAO;IAAE,OAAO;IAAY,MAAM,EAAE,IAAI,OAAO;GAAE,CAAC;GAC1E;EACF;EACA,OAAO,iBAAiB,OAAO,EAAE,OAAO,WAAW,CAAC;CACtD;AACF;;;;;;;AAwBA,SAAgB,+BAAuD;CACrE,MAAM,UAAU,OAAO,mBAAmB,OAAO,cAAc;EAC7D,QAAQ,MAAM,OAAO,UAAU,cAAc;CAC/C,CAAC;CAED,MAAM,UAAiC,OAAO,EAAE,qBAAqB;EACnE,QAAQ,OAAO,EAAE,gBAAgB,kBAAkB,KAAK,CAAC;CAC3D;CACA,OAAO;EAEL,gBAAgB,OAAO,cAAc;GACnC,IAAI,WAAW,KAAK,KAAK,WAAW,KAAK,GAAG;GAC5C,OAAO,OAAO,SAAS;EACzB;EACA,iBAAiB;EACjB,oBAAoB;CACtB;AACF;;AAGA,SAAgB,iBAAiB,SAA8D;CAC7F,MAAM,SAAS,OAAO,KAAK,0BAA0B,OAAO,CAAC;CAC7D,mBAAmB,4BAA4B,CAAC;CAChD,OAAO;AACT;;AAGA,SAAgB,uBACd,QACM;CACN,IAAI,OAAO,UAAU;CACrB,OAAO,eAAe,OAAO,wCAAwC,MAAM,CAAC;AAC9E;;AAGA,MAAa,gBAA6C,OAAO"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://www.schemastore.org/package.json",
|
|
3
3
|
"name": "better-ship",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.35.0",
|
|
5
5
|
"description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"./better-auth/stripe": "./dist/better-auth/stripe.js",
|
|
28
28
|
"./billing/client": "./dist/billing/client.js",
|
|
29
29
|
"./bin/notify-ci": "./dist/bin/notify-ci.js",
|
|
30
|
+
"./browser-support/floor": "./dist/browser-support/floor.js",
|
|
31
|
+
"./browser-support/oxlint": "./dist/browser-support/oxlint.js",
|
|
32
|
+
"./browser-support/polyfills": "./dist/browser-support/polyfills.js",
|
|
33
|
+
"./browser-support/update-screen": "./dist/browser-support/update-screen.js",
|
|
30
34
|
"./cloudflare": "./dist/cloudflare.js",
|
|
31
35
|
"./cloudflare/auth-secondary-storage": "./dist/cloudflare/auth-secondary-storage.js",
|
|
32
36
|
"./cloudflare/durable-object-client": "./dist/cloudflare/durable-object-client.js",
|
|
@@ -80,9 +84,12 @@
|
|
|
80
84
|
"@better-auth/stripe": "^1.7.7",
|
|
81
85
|
"@cloudflare/vitest-plugin": "^1.3.4",
|
|
82
86
|
"@cloudflare/workers-types": "5.20260930.2",
|
|
87
|
+
"@mdn/browser-compat-data": "^8.1.4",
|
|
88
|
+
"@oxlint/plugins": "^1.86.0",
|
|
83
89
|
"@posthog/react": "^1.11.3",
|
|
84
90
|
"@sentry/cloudflare": "^11.2.0",
|
|
85
91
|
"@sentry/tanstackstart-react": "^11.2.0",
|
|
92
|
+
"@tailwindcss/node": "^4.3.3",
|
|
86
93
|
"@tanstack/react-router": "^1.170.41",
|
|
87
94
|
"@tanstack/react-start": "^1.168.60",
|
|
88
95
|
"@tanstack/start-client-core": "^1.170.34",
|
|
@@ -93,6 +100,7 @@
|
|
|
93
100
|
"@vitest/browser-playwright": "^4.1.11",
|
|
94
101
|
"auth": "^1.7.7",
|
|
95
102
|
"better-auth": "^1.7.7",
|
|
103
|
+
"browserslist": "^4.29.3",
|
|
96
104
|
"drizzle-kit": "^0.31.11",
|
|
97
105
|
"drizzle-orm": "^0.45.3",
|
|
98
106
|
"pg": "^8.23.1",
|
|
@@ -110,12 +118,15 @@
|
|
|
110
118
|
},
|
|
111
119
|
"peerDependencies": {
|
|
112
120
|
"@better-auth/stripe": "^1.7.7",
|
|
121
|
+
"@mdn/browser-compat-data": "^8.1.4",
|
|
122
|
+
"@oxlint/plugins": "^1.86.0",
|
|
113
123
|
"@posthog/react": "^1.11.3",
|
|
114
124
|
"@sentry/cloudflare": "^11.2.0",
|
|
115
125
|
"@sentry/tanstackstart-react": "^11.2.0",
|
|
116
126
|
"@tanstack/react-router": "^1.170.41",
|
|
117
127
|
"@tanstack/react-start": "^1.168.60",
|
|
118
128
|
"better-auth": "^1.7.7",
|
|
129
|
+
"browserslist": "^4.29.3",
|
|
119
130
|
"drizzle-orm": "^0.45.3",
|
|
120
131
|
"pg": "^8.23.1",
|
|
121
132
|
"posthog-js": "^1.435.6",
|
|
@@ -166,6 +177,15 @@
|
|
|
166
177
|
},
|
|
167
178
|
"pg": {
|
|
168
179
|
"optional": true
|
|
180
|
+
},
|
|
181
|
+
"@mdn/browser-compat-data": {
|
|
182
|
+
"optional": true
|
|
183
|
+
},
|
|
184
|
+
"@oxlint/plugins": {
|
|
185
|
+
"optional": true
|
|
186
|
+
},
|
|
187
|
+
"browserslist": {
|
|
188
|
+
"optional": true
|
|
169
189
|
}
|
|
170
190
|
},
|
|
171
191
|
"engines": {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AppError } from '@/core'
|
|
2
|
+
|
|
3
|
+
/** The browserslist file is missing, empty, or not a query browserslist can resolve. */
|
|
4
|
+
export class BrowserFloorError extends AppError {
|
|
5
|
+
readonly _tag = 'BrowserFloorError'
|
|
6
|
+
readonly classification = 'terminal'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `path` is the file or directory passed to `browserFloor`.
|
|
10
|
+
* `reason` is `missing` when no config is found, `empty` when the query matches no browsers,
|
|
11
|
+
* and `invalid` when browserslist rejects the query. `cause` is that rejection.
|
|
12
|
+
*/
|
|
13
|
+
constructor(
|
|
14
|
+
readonly path: string,
|
|
15
|
+
readonly reason: 'missing' | 'empty' | 'invalid',
|
|
16
|
+
cause?: unknown,
|
|
17
|
+
) {
|
|
18
|
+
super(floorMessage(reason, path), { cause })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function floorMessage(reason: 'missing' | 'empty' | 'invalid', path: string): string {
|
|
23
|
+
switch (reason) {
|
|
24
|
+
case 'missing':
|
|
25
|
+
return `No browserslist config at ${path}`
|
|
26
|
+
case 'empty':
|
|
27
|
+
return `The browserslist config at ${path} matches no browsers`
|
|
28
|
+
case 'invalid':
|
|
29
|
+
return `The browserslist config at ${path} is invalid`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import browserslist from 'browserslist'
|
|
2
|
+
|
|
3
|
+
import { BrowserFloorError } from './browser-floor.error.ts'
|
|
4
|
+
|
|
5
|
+
export { BrowserFloorError }
|
|
6
|
+
|
|
7
|
+
/** Oldest and newest version of one browser that the floor still includes. */
|
|
8
|
+
export type BrowserFloorRelease = {
|
|
9
|
+
readonly browser: string
|
|
10
|
+
readonly minimum: string
|
|
11
|
+
readonly maximum: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Title and body for the update screen, taken from the floor versions. */
|
|
15
|
+
export type UpdateScreenCopy = {
|
|
16
|
+
readonly title: string
|
|
17
|
+
readonly body: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the minimum and maximum version of each browser in the browserslist config.
|
|
22
|
+
* The path is a config file or any file inside the project that owns `.browserslistrc`.
|
|
23
|
+
* Throws `BrowserFloorError` when the config is missing, empty, or invalid.
|
|
24
|
+
*/
|
|
25
|
+
export function browserFloor(path: string): readonly BrowserFloorRelease[] {
|
|
26
|
+
const queries = loadQueries(path)
|
|
27
|
+
if (queries === undefined) throw new BrowserFloorError(path, 'missing')
|
|
28
|
+
if (queries.length === 0) throw new BrowserFloorError(path, 'empty')
|
|
29
|
+
let resolved: readonly string[]
|
|
30
|
+
try {
|
|
31
|
+
resolved = browserslist(queries, { path })
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
throw new BrowserFloorError(path, 'invalid', cause)
|
|
34
|
+
}
|
|
35
|
+
const releases = floorReleases(resolved)
|
|
36
|
+
if (releases.length === 0) throw new BrowserFloorError(path, 'empty')
|
|
37
|
+
return releases
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Vite `environments.client.build.target` values for the browserslist config at `path`.
|
|
42
|
+
* Throws `BrowserFloorError` when the config is missing, empty, or invalid.
|
|
43
|
+
*/
|
|
44
|
+
export function buildTargets(path: string): readonly string[] {
|
|
45
|
+
return browserFloor(path).map((release) => `${viteBrowser(release.browser)}${release.minimum}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Sentence that names the floor versions. Pass the result of `browserFloor`. */
|
|
49
|
+
export function updateScreenCopy(releases: readonly BrowserFloorRelease[]): UpdateScreenCopy {
|
|
50
|
+
const versions = releases
|
|
51
|
+
.map((release) => `${browserLabel(release.browser)} ${release.minimum}`)
|
|
52
|
+
.join(', ')
|
|
53
|
+
return {
|
|
54
|
+
title: 'Update your browser',
|
|
55
|
+
body: `This application needs ${versions}.`,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadQueries(path: string): readonly string[] | undefined {
|
|
60
|
+
try {
|
|
61
|
+
return browserslist.loadConfig({ path })
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
throw new BrowserFloorError(path, 'invalid', cause)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function floorReleases(resolved: readonly string[]): BrowserFloorRelease[] {
|
|
68
|
+
const ranges = new Map<string, { minimum: string; maximum: string }>()
|
|
69
|
+
for (const entry of resolved) {
|
|
70
|
+
const release = splitRelease(entry)
|
|
71
|
+
if (release === undefined) continue
|
|
72
|
+
const current = ranges.get(release.browser)
|
|
73
|
+
if (current === undefined) {
|
|
74
|
+
ranges.set(release.browser, { minimum: release.version, maximum: release.version })
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (compareVersionText(release.version, current.minimum) < 0) current.minimum = release.version
|
|
78
|
+
if (compareVersionText(release.version, current.maximum) > 0) current.maximum = release.version
|
|
79
|
+
}
|
|
80
|
+
return [...ranges.entries()]
|
|
81
|
+
.map(([browser, range]) => ({ browser, minimum: range.minimum, maximum: range.maximum }))
|
|
82
|
+
.sort((left, right) => compareText(left.browser, right.browser))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function splitRelease(
|
|
86
|
+
entry: string,
|
|
87
|
+
): { readonly browser: string; readonly version: string } | undefined {
|
|
88
|
+
const space = entry.indexOf(' ')
|
|
89
|
+
if (space === -1) return undefined
|
|
90
|
+
const browser = entry.slice(0, space)
|
|
91
|
+
const version = entry.slice(space + 1)
|
|
92
|
+
if (versionParts(version) === undefined) return undefined
|
|
93
|
+
return { browser, version }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function compareVersionText(left: string, right: string): number {
|
|
97
|
+
const leftParts = versionParts(left)
|
|
98
|
+
const rightParts = versionParts(right)
|
|
99
|
+
if (leftParts === undefined || rightParts === undefined) return 0
|
|
100
|
+
return compareVersions(leftParts, rightParts)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function versionParts(version: string): readonly number[] | undefined {
|
|
104
|
+
const normalized =
|
|
105
|
+
version.startsWith('≤') || version.startsWith('<=') ? version.replace(/^≤|^<=/, '') : version
|
|
106
|
+
if (!/^\d+(?:\.\d+)*$/.test(normalized)) return undefined
|
|
107
|
+
return normalized.split('.').map((part) => Number(part))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function compareVersions(left: readonly number[], right: readonly number[]): number {
|
|
111
|
+
const length = Math.max(left.length, right.length)
|
|
112
|
+
for (let index = 0; index < length; index += 1) {
|
|
113
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0)
|
|
114
|
+
if (difference !== 0) return difference
|
|
115
|
+
}
|
|
116
|
+
return 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function compareText(left: string, right: string): number {
|
|
120
|
+
if (left < right) return -1
|
|
121
|
+
if (left > right) return 1
|
|
122
|
+
return 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function viteBrowser(browser: string): string {
|
|
126
|
+
if (browser === 'ios_saf') return 'ios'
|
|
127
|
+
return browser
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function browserLabel(browser: string): string {
|
|
131
|
+
switch (browser) {
|
|
132
|
+
case 'chrome':
|
|
133
|
+
return 'Chrome'
|
|
134
|
+
case 'edge':
|
|
135
|
+
return 'Edge'
|
|
136
|
+
case 'firefox':
|
|
137
|
+
return 'Firefox'
|
|
138
|
+
case 'safari':
|
|
139
|
+
return 'Safari'
|
|
140
|
+
case 'ios_saf':
|
|
141
|
+
return 'iOS'
|
|
142
|
+
default:
|
|
143
|
+
return browser
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { eslintCompatPlugin } from '@oxlint/plugins'
|
|
2
|
+
|
|
3
|
+
import { noUnsupportedApiRule } from './no-unsupported-api.ts'
|
|
4
|
+
|
|
5
|
+
/** Oxlint plugin. Enable `browser-support/no-unsupported-api` in `jsPlugins`. */
|
|
6
|
+
const browserSupportPlugin = eslintCompatPlugin({
|
|
7
|
+
meta: { name: 'browser-support' },
|
|
8
|
+
rules: {
|
|
9
|
+
'no-unsupported-api': noUnsupportedApiRule,
|
|
10
|
+
},
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export default browserSupportPlugin
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BrowserName,
|
|
3
|
+
CompatStatement,
|
|
4
|
+
Identifier,
|
|
5
|
+
SimpleSupportStatement,
|
|
6
|
+
SupportStatement,
|
|
7
|
+
} from '@mdn/browser-compat-data/forLegacyNode'
|
|
8
|
+
import bcd from '@mdn/browser-compat-data/forLegacyNode'
|
|
9
|
+
import { defineRule } from '@oxlint/plugins'
|
|
10
|
+
import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins'
|
|
11
|
+
|
|
12
|
+
import type { BrowserFloorRelease } from '../floor.ts'
|
|
13
|
+
import { BrowserFloorError, browserFloor } from '../floor.ts'
|
|
14
|
+
import { POLYFILLED_APIS } from '../polyfills.ts'
|
|
15
|
+
|
|
16
|
+
const polyfilledApis = new Set<string>(POLYFILLED_APIS)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reject a global static call that a browser in the floor does not implement.
|
|
20
|
+
* `POLYFILLED_APIS` and a `// FALLBACK:` comment on the statement are allowed.
|
|
21
|
+
* The floor is the nearest `.browserslistrc` from the linted file.
|
|
22
|
+
*/
|
|
23
|
+
export const noUnsupportedApiRule = defineRule({
|
|
24
|
+
meta: {
|
|
25
|
+
type: 'problem',
|
|
26
|
+
docs: {
|
|
27
|
+
description:
|
|
28
|
+
'Disallow a platform API that a browser in the browserslist floor does not implement.',
|
|
29
|
+
},
|
|
30
|
+
messages: {
|
|
31
|
+
unsupported: '{{api}} is missing in {{browsers}}.',
|
|
32
|
+
floor: 'The browserslist floor for {{path}} is {{reason}}.',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
createOnce(context) {
|
|
36
|
+
const floors = new Map<string, readonly BrowserFloorRelease[] | BrowserFloorError>()
|
|
37
|
+
const loadFloor = (path: string): readonly BrowserFloorRelease[] | BrowserFloorError => {
|
|
38
|
+
const cached = floors.get(path)
|
|
39
|
+
if (cached !== undefined) return cached
|
|
40
|
+
try {
|
|
41
|
+
const releases = browserFloor(path)
|
|
42
|
+
floors.set(path, releases)
|
|
43
|
+
return releases
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
if (!(cause instanceof BrowserFloorError)) throw cause
|
|
46
|
+
floors.set(path, cause)
|
|
47
|
+
return cause
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
Program(node) {
|
|
53
|
+
const floor = loadFloor(context.filename)
|
|
54
|
+
if (!(floor instanceof BrowserFloorError)) return
|
|
55
|
+
context.report({
|
|
56
|
+
node,
|
|
57
|
+
messageId: 'floor',
|
|
58
|
+
data: { path: context.filename, reason: floor.reason },
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
MemberExpression(node) {
|
|
62
|
+
const api = staticApi(node)
|
|
63
|
+
if (api === undefined) return
|
|
64
|
+
if (node.object.type !== 'Identifier') return
|
|
65
|
+
if (!isPlatformReference(context.sourceCode, node.object)) return
|
|
66
|
+
if (markedFallback(context.sourceCode, node)) return
|
|
67
|
+
if (polyfilledApis.has(api)) return
|
|
68
|
+
const floor = loadFloor(context.filename)
|
|
69
|
+
if (floor instanceof BrowserFloorError) return
|
|
70
|
+
const compat = compatFor(node.object.name, memberName(node))
|
|
71
|
+
if (compat === undefined) return
|
|
72
|
+
const browsers = unsupportedBrowsers(compat, floor)
|
|
73
|
+
if (browsers.length === 0) return
|
|
74
|
+
context.report({
|
|
75
|
+
node,
|
|
76
|
+
messageId: 'unsupported',
|
|
77
|
+
data: { api, browsers: browsers.join(', ') },
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
function staticApi(node: ESTree.MemberExpression): string | undefined {
|
|
85
|
+
if (node.computed || node.object.type !== 'Identifier' || node.property.type !== 'Identifier') {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
return `${node.object.name}.${node.property.name}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function memberName(node: ESTree.MemberExpression): string {
|
|
92
|
+
if (node.property.type !== 'Identifier') return ''
|
|
93
|
+
return node.property.name
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isPlatformReference(
|
|
97
|
+
sourceCode: SourceCode,
|
|
98
|
+
identifier: ESTree.IdentifierReference,
|
|
99
|
+
): boolean {
|
|
100
|
+
const variable = resolveVariable(sourceCode, identifier)
|
|
101
|
+
if (variable === null || variable.defs.length === 0) return true
|
|
102
|
+
return variable.defs.every((definition) => definition.type === 'ImplicitGlobalVariable')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveVariable(
|
|
106
|
+
sourceCode: SourceCode,
|
|
107
|
+
identifier: ESTree.IdentifierReference,
|
|
108
|
+
): Variable | null {
|
|
109
|
+
let scope: Scope | null = sourceCode.getScope(identifier)
|
|
110
|
+
while (scope !== null) {
|
|
111
|
+
const variable = scope.set.get(identifier.name)
|
|
112
|
+
if (variable !== undefined) return variable
|
|
113
|
+
scope = scope.upper
|
|
114
|
+
}
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function markedFallback(sourceCode: SourceCode, node: ESTree.Node): boolean {
|
|
119
|
+
let current: ESTree.Node | null = node
|
|
120
|
+
while (current !== null && current.type !== 'Program') {
|
|
121
|
+
const comments = [
|
|
122
|
+
...sourceCode.getCommentsBefore(current),
|
|
123
|
+
...sourceCode.getCommentsAfter(current),
|
|
124
|
+
]
|
|
125
|
+
if (comments.some((comment) => comment.value.includes('FALLBACK:'))) return true
|
|
126
|
+
current = current.parent
|
|
127
|
+
}
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function compatFor(objectName: string, name: string): CompatStatement | undefined {
|
|
132
|
+
const builtins = bcd.javascript.builtins
|
|
133
|
+
const fromJavascript = builtins === undefined ? undefined : lookup(builtins, objectName, name)
|
|
134
|
+
return fromJavascript ?? lookup(bcd.api, objectName, name)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// BCD stores `URL.parse` as `parse_static`. The plain key is the instance or the builtin.
|
|
138
|
+
function lookup(
|
|
139
|
+
namespace: Identifier,
|
|
140
|
+
objectName: string,
|
|
141
|
+
name: string,
|
|
142
|
+
): CompatStatement | undefined {
|
|
143
|
+
const objectData = namespace[objectName]
|
|
144
|
+
if (objectData === undefined) return undefined
|
|
145
|
+
return objectData[`${name}_static`]?.__compat ?? objectData[name]?.__compat
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function unsupportedBrowsers(
|
|
149
|
+
compat: CompatStatement,
|
|
150
|
+
floor: readonly BrowserFloorRelease[],
|
|
151
|
+
): readonly string[] {
|
|
152
|
+
const missing: string[] = []
|
|
153
|
+
for (const release of floor) {
|
|
154
|
+
const browser = compatBrowser(release.browser)
|
|
155
|
+
if (browser === undefined || !rangeSupports(compat.support[browser], release)) {
|
|
156
|
+
missing.push(`${release.browser} ${release.minimum}`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return missing
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function compatBrowser(browser: string): BrowserName | undefined {
|
|
163
|
+
switch (browser) {
|
|
164
|
+
case 'chrome':
|
|
165
|
+
case 'edge':
|
|
166
|
+
case 'firefox':
|
|
167
|
+
case 'safari':
|
|
168
|
+
return browser
|
|
169
|
+
case 'ios_saf':
|
|
170
|
+
return 'safari_ios'
|
|
171
|
+
default:
|
|
172
|
+
return undefined
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function rangeSupports(
|
|
177
|
+
support: SupportStatement | undefined,
|
|
178
|
+
release: BrowserFloorRelease,
|
|
179
|
+
): boolean {
|
|
180
|
+
if (support === undefined) return false
|
|
181
|
+
const statements = Array.isArray(support) ? support : [support]
|
|
182
|
+
return statements.some((statement) => statementSupports(statement, release))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// An alternative_name or a prefix is a different API. A flag is off by default.
|
|
186
|
+
function statementSupports(
|
|
187
|
+
statement: SimpleSupportStatement,
|
|
188
|
+
release: BrowserFloorRelease,
|
|
189
|
+
): boolean {
|
|
190
|
+
if (statement.alternative_name !== undefined) return false
|
|
191
|
+
if (statement.prefix !== undefined) return false
|
|
192
|
+
if (statement.flags !== undefined) return false
|
|
193
|
+
if (statement.version_added === false) return false
|
|
194
|
+
const added = versionParts(statement.version_added)
|
|
195
|
+
const minimum = versionParts(release.minimum)
|
|
196
|
+
if (added === undefined || minimum === undefined) return false
|
|
197
|
+
if (compareVersions(minimum, added) < 0) return false
|
|
198
|
+
if (statement.version_removed === undefined) return true
|
|
199
|
+
const removed = versionParts(statement.version_removed)
|
|
200
|
+
const maximum = versionParts(release.maximum)
|
|
201
|
+
if (removed === undefined || maximum === undefined) return true
|
|
202
|
+
return compareVersions(maximum, removed) < 0
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function versionParts(version: string): readonly number[] | undefined {
|
|
206
|
+
const normalized =
|
|
207
|
+
version.startsWith('≤') || version.startsWith('<=') ? version.replace(/^≤|^<=/, '') : version
|
|
208
|
+
if (!/^\d+(?:\.\d+)*$/.test(normalized)) return undefined
|
|
209
|
+
return normalized.split('.').map((part) => Number(part))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function compareVersions(left: readonly number[], right: readonly number[]): number {
|
|
213
|
+
const length = Math.max(left.length, right.length)
|
|
214
|
+
for (let index = 0; index < length; index += 1) {
|
|
215
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0)
|
|
216
|
+
if (difference !== 0) return difference
|
|
217
|
+
}
|
|
218
|
+
return 0
|
|
219
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Platform APIs `installPolyfills` provides. The lint rule allows these names. */
|
|
2
|
+
export const POLYFILLED_APIS = ['URL.parse'] as const
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Install `URL.parse` when the host has none.
|
|
6
|
+
* A native `URL.parse` on `URL` stays in place.
|
|
7
|
+
*/
|
|
8
|
+
export function installPolyfills(): void {
|
|
9
|
+
if (Object.getOwnPropertyDescriptor(URL, 'parse') !== undefined) return
|
|
10
|
+
Object.defineProperty(URL, 'parse', {
|
|
11
|
+
configurable: true,
|
|
12
|
+
writable: true,
|
|
13
|
+
value: parseUrl,
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseUrl(url: string | URL, base?: string | URL): URL | null {
|
|
18
|
+
try {
|
|
19
|
+
if (base === undefined) return new URL(url)
|
|
20
|
+
return new URL(url, base)
|
|
21
|
+
} catch (cause) {
|
|
22
|
+
if (cause instanceof TypeError) return null
|
|
23
|
+
throw cause
|
|
24
|
+
}
|
|
25
|
+
}
|