better-ship 0.34.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/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
|
@@ -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/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
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** Set on `documentElement` when the update screen is showing. Hydration checks this name. */
|
|
2
|
+
export const unsupportedBrowserAttribute = 'data-unsupported-browser'
|
|
3
|
+
|
|
4
|
+
const updateTitleAttribute = 'data-update-title' as const
|
|
5
|
+
const updateBodyAttribute = 'data-update-body' as const
|
|
6
|
+
const fallbackFont = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif'
|
|
7
|
+
|
|
8
|
+
/** Copy and public URLs for the update screen tags in the document head. */
|
|
9
|
+
export type UpdateScreenHead = {
|
|
10
|
+
readonly title: string
|
|
11
|
+
readonly body: string
|
|
12
|
+
/** Public URL of `updateScreenStyle`. */
|
|
13
|
+
readonly styleHref: string
|
|
14
|
+
/** Public URL of `updateScreenScript`. */
|
|
15
|
+
readonly scriptSrc: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Stylesheet link a root route places in `head().links`. */
|
|
19
|
+
export type UpdateScreenLink = {
|
|
20
|
+
readonly rel: 'stylesheet'
|
|
21
|
+
readonly href: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Deferred script a root route places in `head().scripts`. */
|
|
25
|
+
export type UpdateScreenScript = {
|
|
26
|
+
readonly src: string
|
|
27
|
+
readonly defer: true
|
|
28
|
+
readonly 'data-update-title': string
|
|
29
|
+
readonly 'data-update-body': string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `links` and `scripts` a root route spreads into `head()`.
|
|
34
|
+
* The arrays are mutable so they match the `head()` return type.
|
|
35
|
+
*/
|
|
36
|
+
export type UpdateScreenHeadEntries = {
|
|
37
|
+
links: UpdateScreenLink[]
|
|
38
|
+
scripts: UpdateScreenScript[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Stylesheet and deferred script entries for a root route `head()`.
|
|
43
|
+
* Spread `links` and `scripts` into `head()`. The router escapes the attribute values.
|
|
44
|
+
* `defer` keeps parsing unblocked. The script is classic ES5, so a browser below the floor can show the screen.
|
|
45
|
+
*/
|
|
46
|
+
export function updateScreenHeadScript(head: UpdateScreenHead): UpdateScreenHeadEntries {
|
|
47
|
+
return {
|
|
48
|
+
links: [{ rel: 'stylesheet', href: head.styleHref }],
|
|
49
|
+
scripts: [
|
|
50
|
+
{
|
|
51
|
+
src: head.scriptSrc,
|
|
52
|
+
defer: true,
|
|
53
|
+
[updateTitleAttribute]: head.title,
|
|
54
|
+
[updateBodyAttribute]: head.body,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when the update screen script marked this element. Skip hydration when it is true. */
|
|
61
|
+
export function isUnsupportedBrowser(root: {
|
|
62
|
+
readonly hasAttribute: (name: string) => boolean
|
|
63
|
+
}): boolean {
|
|
64
|
+
return root.hasAttribute(unsupportedBrowserAttribute)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Classic ES5 script. Serve it at `scriptSrc`.
|
|
69
|
+
* It shows the screen when `CSS.registerProperty` or `color-mix()` is missing, and does nothing at the floor.
|
|
70
|
+
*/
|
|
71
|
+
export const updateScreenScript = `(function () {
|
|
72
|
+
var cssApi = typeof CSS === 'undefined' ? null : CSS;
|
|
73
|
+
var hasProperty = cssApi !== null && typeof cssApi.registerProperty === 'function';
|
|
74
|
+
var hasMix = cssApi !== null && typeof cssApi.supports === 'function' && cssApi.supports('color', 'color-mix(in srgb, red, blue)');
|
|
75
|
+
if (hasProperty && hasMix) return;
|
|
76
|
+
document.documentElement.setAttribute('${unsupportedBrowserAttribute}', '');
|
|
77
|
+
var scripts = document.getElementsByTagName('script');
|
|
78
|
+
var title = 'Update your browser';
|
|
79
|
+
var body = '';
|
|
80
|
+
for (var index = 0; index < scripts.length; index = index + 1) {
|
|
81
|
+
var marker = scripts[index].getAttribute('${updateTitleAttribute}');
|
|
82
|
+
if (marker === null) continue;
|
|
83
|
+
title = marker;
|
|
84
|
+
var text = scripts[index].getAttribute('${updateBodyAttribute}');
|
|
85
|
+
body = text === null ? '' : text;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
var screen = document.createElement('div');
|
|
89
|
+
screen.id = 'unsupported-browser';
|
|
90
|
+
var heading = document.createElement('h1');
|
|
91
|
+
heading.className = 'unsupported-browser-title';
|
|
92
|
+
heading.textContent = title;
|
|
93
|
+
var paragraph = document.createElement('p');
|
|
94
|
+
paragraph.className = 'unsupported-browser-body';
|
|
95
|
+
paragraph.textContent = body;
|
|
96
|
+
screen.appendChild(heading);
|
|
97
|
+
screen.appendChild(paragraph);
|
|
98
|
+
document.body.appendChild(screen);
|
|
99
|
+
})();
|
|
100
|
+
`
|
|
101
|
+
|
|
102
|
+
// `!important` keeps application styles from painting over the update screen.
|
|
103
|
+
/** Classic CSS for the update screen. Serve it at `styleHref`. Tokens fall back to neutral grey and a system sans-serif. */
|
|
104
|
+
export const updateScreenStyle = `html[${unsupportedBrowserAttribute}],
|
|
105
|
+
html[${unsupportedBrowserAttribute}] body {
|
|
106
|
+
margin: 0;
|
|
107
|
+
min-height: 100%;
|
|
108
|
+
background: var(--band, #f5f5f5);
|
|
109
|
+
}
|
|
110
|
+
html[${unsupportedBrowserAttribute}] body > :not(#unsupported-browser) {
|
|
111
|
+
display: none !important;
|
|
112
|
+
}
|
|
113
|
+
#unsupported-browser {
|
|
114
|
+
box-sizing: border-box;
|
|
115
|
+
min-height: 100vh;
|
|
116
|
+
margin: 0;
|
|
117
|
+
padding: 48px 24px;
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
justify-content: center;
|
|
121
|
+
align-items: center;
|
|
122
|
+
text-align: center;
|
|
123
|
+
background: var(--band, #f5f5f5);
|
|
124
|
+
color: var(--foreground, #171717);
|
|
125
|
+
font-family: var(--font-text, ${fallbackFont});
|
|
126
|
+
}
|
|
127
|
+
.unsupported-browser-title {
|
|
128
|
+
margin: 0 0 12px;
|
|
129
|
+
max-width: 28rem;
|
|
130
|
+
font-family: var(--font-brand, ${fallbackFont});
|
|
131
|
+
font-size: 2rem;
|
|
132
|
+
font-weight: 500;
|
|
133
|
+
line-height: 1.2;
|
|
134
|
+
}
|
|
135
|
+
.unsupported-browser-body {
|
|
136
|
+
margin: 0;
|
|
137
|
+
max-width: 32rem;
|
|
138
|
+
color: var(--muted-foreground, #525252);
|
|
139
|
+
font-size: 1.0625rem;
|
|
140
|
+
line-height: 1.5;
|
|
141
|
+
}
|
|
142
|
+
`
|