Compare commits

...

14 Commits

Author SHA1 Message Date
20d2c1b7a0
version bump 2022-10-18 13:58:53 +02:00
008d1c949d
stuff 2022-10-18 13:58:37 +02:00
0fc63ed17d
Merge branch 'main' of https://github.com/cupcakearmy/morphus 2022-05-29 11:31:20 +02:00
a47d5974e4
stuff 2022-05-29 11:31:18 +02:00
21f570a289
Update README.md 2022-05-29 11:08:05 +02:00
9500bcf68c
logo 2022-05-29 10:42:02 +02:00
5bfcacab58
readme 2022-05-29 01:49:55 +02:00
38d35f40aa
add presets 2022-05-29 01:19:18 +02:00
08470ba820
remove caniuse integration in favor of headers 2022-05-29 00:09:22 +02:00
552fb3c572
strict engines 2022-05-29 00:09:09 +02:00
7412b94fb5
include in docker compose 2022-05-29 00:09:03 +02:00
f89ed1b976
cleanup docker 2022-05-29 00:08:49 +02:00
ab609fcb33
Update README.md 2022-05-28 17:04:30 +02:00
32b1d8f38a
Update README.md 2022-02-05 23:56:10 +01:00
15 changed files with 733 additions and 736 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ dist
assets assets
*.tsbuildinfo *.tsbuildinfo
data data
demo
.vscode .vscode
# Configs # Configs

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

View File

@ -1,19 +1,28 @@
FROM node:16-alpine as builder # BASE
FROM node:16-alpine as base
WORKDIR /app WORKDIR /app
RUN npm -g i pnpm@7
# Needed for node-gyp
RUN apk add --no-cache python3
# BUILDER
FROM base as builder
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN npm exec pnpm i --frozen-lockfile RUN pnpm i --frozen-lockfile
COPY . . COPY . .
RUN npm exec pnpm run build RUN pnpm run build
RUN ls -hal RUN ls -hal
FROM node:16-alpine # RUNNER
FROM base
ENV NODE_ENV=production
WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN npm exec pnpm i --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist

View File

@ -6,10 +6,17 @@
<br><br> <br><br>
</p> </p>
<a href="https://discord.gg/nuby6RnxZt">
<img alt="discord" src="https://img.shields.io/discord/252403122348097536?style=for-the-badge" />
<img alt="docker pulls" src="https://img.shields.io/docker/pulls/cupcakearmy/morphus?style=for-the-badge" />
<img alt="Docker image size badge" src="https://img.shields.io/docker/image-size/cupcakearmy/morphus?style=for-the-badge" />
<img alt="Latest version" src="https://img.shields.io/github/v/release/cupcakearmy/moprhus?style=for-the-badge" />
</a>
A lightweight image resizing and effect proxy that caches image transformations. A lightweight image resizing and effect proxy that caches image transformations.
The heavy lifting is done by [`libvips`](https://github.com/libvips/libvips) and [`sharp`](https://github.com/lovell/sharp) The heavy lifting is done by [`libvips`](https://github.com/libvips/libvips) and [`sharp`](https://github.com/lovell/sharp)
> **⚠️ Currently under development, see the [milestone](https://github.com/cupcakearmy/morphus/milestone/1) for status.** > **⚠️ Currently under development**
## 🌈 Features ## 🌈 Features
@ -17,8 +24,9 @@ The heavy lifting is done by [`libvips`](https://github.com/libvips/libvips) and
- Domain protection - Domain protection
- Host verification - Host verification
- Multiple storage adapters (Local, Minio, S3, GCP) - Multiple storage adapters (Local, Minio, S3, GCP)
- Auto format based on `Accept` header and `user-agent`. - Auto format based on `Accept` header
- ETag caching - ETag caching
- Presets and optional forcing of presets
## 🏗 Installation ## 🏗 Installation
@ -64,7 +72,7 @@ https://my-morphus.org/api/image?format=webp&width=2000&resize=contain&url=https
**Chose a format with a given quality**: `?format=webp|quality:90` **Chose a format with a given quality**: `?format=webp|quality:90`
``` ```
http://localhost:3000/api/image?format=webp|quality:90&width=2000&resize=contain&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b https://my-morphus.org/api/image?format=webp|quality:90&width=2000&resize=contain&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
``` ```
**With some transformation operations**: `?op=rotate|angle:90&op=sharpen|sigma:1,flat:2` **With some transformation operations**: `?op=rotate|angle:90&op=sharpen|sigma:1,flat:2`
@ -72,7 +80,19 @@ http://localhost:3000/api/image?format=webp|quality:90&width=2000&resize=contain
This is transforming the image once by `rotate` with the argument `angle: 90` and `sharpen` with the arguments of `sigma: 1` and `flat: 2`. This is transforming the image once by `rotate` with the argument `angle: 90` and `sharpen` with the arguments of `sigma: 1` and `flat: 2`.
``` ```
http://localhost:3000/api/image?width=2000&resize=contain&op=rotate|angle:90&op=sharpen|sigma:1,flat:2&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b https://my-morphus.org/api/image?width=2000&resize=contain&op=rotate|angle:90&op=sharpen|sigma:1,flat:2&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
```
**With custom presets**: `?preset=thumbnail`
```yaml
# morphus.yaml
presets:
thumbnail: ?width=300&height=150&resize=contain
```
```
https://my-morphus.org/api/image?preset=thumbnail&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
``` ```
## 💻 Usage ## 💻 Usage
@ -108,16 +128,18 @@ Config files are searched in the current working directory under `morphus.yaml`.
Configuration can be done either thorough config files or env variables. The usage of a config file is recommended. Below is a table of available configuration options, for more details see below. Configuration can be done either thorough config files or env variables. The usage of a config file is recommended. Below is a table of available configuration options, for more details see below.
| Config | Environment | Default | Description | | Config | Environment | Default | Description |
| ---------------- | -------------------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------ | -------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `port` | `PORT` | 80 | The port to bind | | `port` | `PORT` | 80 | The port to bind. |
| `address` | `ADDRESS` | 127.0.0.1 | The address to bind | | `address` | `ADDRESS` | 127.0.0.1 | The address to bind. |
| `logLevel` | `LOG_LEVEL` | info | The [log level](https://getpino.io/#/docs/api?id=loggerlevel-string-gettersetter) to use. Possible values: trace, debug, info, warn, error, fatal, silent | | `logLevel` | `LOG_LEVEL` | info | The [log level](https://getpino.io/#/docs/api?id=loggerlevel-string-gettersetter) to use. Possible values: trace, debug, info, warn, error, fatal, silent. |
| `allowedDomains` | `ALLOWED_DOMAINS` [unsupported for now as ENV](https://github.com/mozilla/node-convict/issues/399) | null | The domains that are allowed to be used as image sources | | `presets` | not available | null | Predefined presets. See below for an example. |
| `allowedHosts` | `ALLOWED_HOSTS` [unsupported for now as ENV](https://github.com/mozilla/node-convict/issues/399) | null | The hosts that are allowed to access the images | | `onlyAllowPresets` | `ONLY_ALLOW_PRESETS` | false | Whether to allow only presets. This can prevent unfair usage. |
| `cleanUrls` | `CLEAN_URL` | Fragment | Whether source URLs are cleaned | | `allowedDomains` | [unsupported for now as ENV](https://github.com/mozilla/node-convict/issues/399) | null | The domains that are allowed to be used as image sources. |
| `maxAge` | `MAX_AGE` | 1d | How long the served images are marked as cached, after that ETag is used to revalidate | | `allowedHosts` | [unsupported for now as ENV](https://github.com/mozilla/node-convict/issues/399) | null | The hosts that are allowed to access the images. |
| `storage` | `STORAGE` | `local` | The storage driver to use. Possible values: `local`, `minio`, `s3`, `gcs`. | | `cleanUrls` | `CLEAN_URL` | Fragment | Whether source URLs are cleaned. |
| `maxAge` | `MAX_AGE` | 1d | How long the served images are marked as cached, after that ETag is used to revalidate. |
| `storage` | `STORAGE` | `local` | The storage driver to use. Possible values: `local`, `minio`, `s3`, `gcs`. |
### Storage Drivers ### Storage Drivers
@ -186,12 +208,42 @@ s3:
```yaml ```yaml
# morphus.yaml # morphus.yaml
storage: gsc storage: gcs
gcs: gcs:
bucket: morphus bucket: morphus
keyFilename: keyfile.json keyFilename: keyfile.json
``` ```
### Presets
With the help of presets you can give predefined sets of operations and transformations.
Clients then can use the presets without specifying the exact parameters.
The syntax is an object that maps a preset name to a value. The value is a valid url query.
```yaml
presets:
sm: ?format=webp|quality:90&width=500&resize=contain
md: ?format=webp|quality:90&width=1000&resize=contain
lg: ?format=webp|quality:90&width=2000&resize=contain
```
A client can the request an image with the following url
```
https://my-morphus.org/api/image?preset=sm&url=https://images.unsplash.com/photo-1636839270984-1f7cbc2b4c4b
```
### Only allow presets
This feature can help reduce abuse of the server by only allowing.
When `onlyAllowPresets` is set no other parameter is allowed besides `url` and `preset`.
If possible it's recommended to turn this on.
```yaml
onlyAllowPresets: true
```
### Allowed Domains ### Allowed Domains
Allowed domains are a way to secure the service by only allowing certain remote domains as possible sources of images. Allowed domains are a way to secure the service by only allowing certain remote domains as possible sources of images.
@ -226,7 +278,7 @@ allowedHosts:
When using the url in an `<img>` tag you need to add the `<img crossorigin="anonymous">` attribute to enable sending the `origin` header to the server. Read more [here](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-crossorigin) When using the url in an `<img>` tag you need to add the `<img crossorigin="anonymous">` attribute to enable sending the `origin` header to the server. Read more [here](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-crossorigin)
## Clean URLs ### Clean URLs
This option allows cleaning the source URLs to remove duplicates. allowed options are `off`, `fragment`, `query`. This option allows cleaning the source URLs to remove duplicates. allowed options are `off`, `fragment`, `query`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 163 KiB

View File

@ -4,7 +4,16 @@
version: '3.8' version: '3.8'
services: services:
app:
image: cupcakearmy/morphus
build: .
volumes:
- ./morphus.yaml:/app/morphus.yaml:ro
ports:
- 80:80
depends_on:
- s3
s3: s3:
image: minio/minio image: minio/minio
ports: ports:

View File

@ -0,0 +1,4 @@
logLevel: warn
allowedDomains:
- example.org

View File

@ -1,6 +1,6 @@
{ {
"name": "morphus", "name": "morphus",
"version": "0.1.0", "version": "1.0.0-rc.3",
"description": "", "description": "",
"author": "Niccolo Borgioli", "author": "Niccolo Borgioli",
"license": "MIT", "license": "MIT",
@ -11,6 +11,7 @@
}, },
"engines": { "engines": {
"node": "16", "node": "16",
"pnpm": ">=6",
"yarn": "plase-use-pnpm", "yarn": "plase-use-pnpm",
"npm": "plase-use-pnpm" "npm": "plase-use-pnpm"
}, },
@ -19,31 +20,29 @@
"@types/convict": "^6.1.1", "@types/convict": "^6.1.1",
"@types/flat": "^5.0.2", "@types/flat": "^5.0.2",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/minio": "^7.0.12", "@types/minio": "^7.0.14",
"@types/ms": "^0.7.31", "@types/ms": "^0.7.31",
"@types/node": "^16.11.22", "@types/node": "^16.11.66",
"@types/sharp": "^0.29.5", "@types/sharp": "^0.29.5",
"ts-node-dev": "^1.1.8", "ts-node-dev": "^2.0.0",
"typescript": "^4.5.5" "typescript": "^4.8.4"
}, },
"dependencies": { "dependencies": {
"@google-cloud/storage": "^5.18.1", "@fastify/caching": "^7.0.0",
"caniuse-db": "^1.0.30001307", "@fastify/compress": "^5.0.0",
"@fastify/cors": "^7.0.0",
"@google-cloud/storage": "^5.20.5",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"convict": "^6.2.1", "convict": "^6.2.3",
"convict-format-with-validator": "^6.2.0", "convict-format-with-validator": "^6.2.0",
"device-detector-js": "^3.0.1",
"fast-crc32c": "^2.0.0", "fast-crc32c": "^2.0.0",
"fastify": "^3.27.1", "fastify": "^3.29.3",
"fastify-caching": "^6.2.0",
"fastify-compress": "^3.7.0",
"fastify-cors": "^6.0.2",
"flat": "^5.0.2", "flat": "^5.0.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"minio": "^7.0.26", "minio": "^7.0.32",
"ms": "^2.1.3", "ms": "^2.1.3",
"pino-pretty": "^7.5.1", "pino-pretty": "^7.6.1",
"sharp": "^0.29.3", "sharp": "^0.31.1",
"under-pressure": "^5.8.0" "under-pressure": "^5.8.1"
} }
} }

1119
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,9 +2,10 @@ import { config, StorageType } from '../dist/src/config.js'
const schema = config._def const schema = config._def
function stringAsMarkdownCode(string) { const asInlineCode = (s) => '`' + s + '`'
return '`' + string + '`' const formatInline = (s, empty = '') => (s === undefined ? empty : asInlineCode(s))
} const formatEnv = (s) => formatInline(s, 'not supported')
const formatDefault = (s) => formatInline(s, '')
for (const storage of Object.values(StorageType)) { for (const storage of Object.values(StorageType)) {
const storageType = schema[storage] const storageType = schema[storage]
@ -13,7 +14,21 @@ for (const storage of Object.values(StorageType)) {
| ---------------- | ------------------ | ------- | ------------------------ | | ---------------- | ------------------ | ------- | ------------------------ |
` `
for (const [key, value] of Object.entries(storageType)) { for (const [key, value] of Object.entries(storageType)) {
table += `| \`${storage}.${key}\` | \`${value.env}\` | ${value.default} | ${value.doc} |\n` table += `| \`${storage}.${key}\` | ${formatEnv(value.env)} | ${formatDefault(value.default)} | ${value.doc} |\n`
}
console.log(table)
}
{
let table = `
| Config | Environment | Default | Description |
| ------- | ----------- | ------- | ------------ |
`
for (const [key, value] of Object.entries(schema)) {
if (Object.values(StorageType).includes(key)) continue
table += `| ${asInlineCode(key)} | ${formatEnv(value.env)} | ${formatDefault(value.default)} | ${value.doc} |\n`
} }
console.log(table) console.log(table)

View File

@ -30,6 +30,7 @@ const Schema = yaml.DEFAULT_SCHEMA.extend([RegExpTag])
export type NullableStringOrRegexpArray = (string | RegExp)[] | null export type NullableStringOrRegexpArray = (string | RegExp)[] | null
function formatNullableStringOrRegexpArray(values: any) { function formatNullableStringOrRegexpArray(values: any) {
if (values === null) return
if (!Array.isArray(values)) throw new Error('must be an array') if (!Array.isArray(values)) throw new Error('must be an array')
if (values.length === 0) throw new Error('must be an array with at least one element') if (values.length === 0) throw new Error('must be an array with at least one element')
for (const value of values) { for (const value of values) {
@ -39,6 +40,18 @@ function formatNullableStringOrRegexpArray(values: any) {
} }
} }
type PresetsConfig = Record<string, string> | null
function formatPresets(values: any) {
if (values === null) return
if (typeof values === 'object') {
for (const key in values) {
if (typeof values[key] !== 'string') throw new Error('entries for presets must be strings')
}
} else {
throw new Error('presets must be an object or null')
}
}
convict.addParser({ extension: ['yml', 'yaml'], parse: (s) => yaml.load(s, { schema: Schema }) }) convict.addParser({ extension: ['yml', 'yaml'], parse: (s) => yaml.load(s, { schema: Schema }) })
export const config = convict({ export const config = convict({
@ -70,14 +83,14 @@ export const config = convict({
format: formatNullableStringOrRegexpArray, format: formatNullableStringOrRegexpArray,
default: null as NullableStringOrRegexpArray, default: null as NullableStringOrRegexpArray,
nullable: true, nullable: true,
env: 'ALLOWED_DOMAINS', // env: 'ALLOWED_DOMAINS', // See: https://github.com/mozilla/node-convict/issues/399
}, },
allowedHosts: { allowedHosts: {
doc: 'The hosts that are allowed to access the images', doc: 'The hosts that are allowed to access the images',
format: formatNullableStringOrRegexpArray, format: formatNullableStringOrRegexpArray,
default: null as NullableStringOrRegexpArray, default: null as NullableStringOrRegexpArray,
nullable: true, nullable: true,
env: 'ALLOWED_HOSTS', // env: 'ALLOWED_HOSTS', // See: https://github.com/mozilla/node-convict/issues/399
}, },
cleanUrls: { cleanUrls: {
doc: 'Whether to clean URLs', doc: 'Whether to clean URLs',
@ -90,7 +103,7 @@ export const config = convict({
maxAge: { maxAge: {
doc: 'The maximum age of a cached image', doc: 'The maximum age of a cached image',
format: String, format: String,
default: '1d', default: '90d',
env: 'MAX_AGE', env: 'MAX_AGE',
}, },
@ -101,6 +114,20 @@ export const config = convict({
env: 'STORAGE', env: 'STORAGE',
}, },
// Presets
presets: {
doc: 'The presets to use',
format: formatPresets,
nullable: true,
default: null as PresetsConfig,
},
onlyAllowPresets: {
doc: 'Whether to allow only presets',
format: Boolean,
default: false,
env: 'ONLY_ALLOW_PRESETS',
},
// Local storage // Local storage
local: { local: {
assets: { assets: {

View File

@ -14,12 +14,10 @@ import { flatten, unflatten } from 'flat'
import type { IncomingHttpHeaders } from 'http2' import type { IncomingHttpHeaders } from 'http2'
import ms from 'ms' import ms from 'ms'
import sharp, { FitEnum, FormatEnum } from 'sharp' import sharp, { FitEnum, FormatEnum } from 'sharp'
import { App } from '..' import { App } from '..'
import { Config, URLClean } from '../config' import { Config, URLClean } from '../config'
import { storage } from '../storage' import { storage } from '../storage'
import { transform } from '../transform' import { transform } from '../transform'
import { supportsAvif, supportsWebP } from '../utils/caniuse'
import { ForbiddenError } from '../utils/errors' import { ForbiddenError } from '../utils/errors'
import { sha3, sortObjectByKeys, testForPrefixOrRegexp, validateSyncOrFail } from '../utils/utils' import { sha3, sortObjectByKeys, testForPrefixOrRegexp, validateSyncOrFail } from '../utils/utils'
@ -63,6 +61,11 @@ export class ComplexParameter<N = string, T extends object = {}> {
@IsObject() @IsObject()
options: T options: T
/**
* parses a parameter value from a string
*
* @param parameter parameter to parse
*/
constructor(parameter: string) { constructor(parameter: string) {
const [name, optionsRaw] = parameter.split('|') const [name, optionsRaw] = parameter.split('|')
if (!name) throw new Error('Invalid parameter') if (!name) throw new Error('Invalid parameter')
@ -117,8 +120,33 @@ export class TransformQueryBase {
@ValidateNested() @ValidateNested()
op: ComplexParameter[] = [] op: ComplexParameter[] = []
@IsOptional()
@IsString()
preset?: string
constructor(data: any, options: { headers: IncomingHttpHeaders }) { constructor(data: any, options: { headers: IncomingHttpHeaders }) {
Object.assign(this, data) if (Config.onlyAllowPresets) {
const { url, preset, ...rest } = data
if (!preset) {
throw new Error('Preset is required')
}
if (Object.keys(rest).length > 0) {
throw new Error('only preset parameter is allowed')
}
this.url = url
this.preset = data.preset
} else {
Object.assign(this, data)
}
if (this.preset) {
const preset = Config.presets[this.preset]
if (!preset) {
throw new Error('preset not found')
}
const params = Object.fromEntries(new URLSearchParams(preset).entries())
Object.assign(this, params)
}
if (this.width) this.width = parseInt(this.width as any) if (this.width) this.width = parseInt(this.width as any)
if (this.height) this.height = parseInt(this.height as any) if (this.height) this.height = parseInt(this.height as any)
@ -129,8 +157,7 @@ export class TransformQueryBase {
// @ts-ignore // @ts-ignore
this.format = new ComplexParameter((this.format as any) || 'auto') this.format = new ComplexParameter((this.format as any) || 'auto')
if ((this.format.name as string) === 'auto') { if ((this.format.name as string) === 'auto') {
if (!options.headers) throw new Error('cannot use auto format without user agent') if (!options.headers) throw new Error('cannot use auto format without headers')
this.autoFormat(options.headers) this.autoFormat(options.headers)
} }
@ -154,13 +181,7 @@ export class TransformQueryBase {
} }
} }
toString(): string { private autoFormat(headers: IncomingHttpHeaders) {
const data = flatten(this) as Record<string, any>
return new URLSearchParams(sortObjectByKeys(data)).toString()
}
autoFormat(headers: IncomingHttpHeaders) {
const ua = headers['user-agent']
const accept = headers['accept'] // Accept: image/avif,image/webp,*/* const accept = headers['accept'] // Accept: image/avif,image/webp,*/*
if (accept) { if (accept) {
const acceptTypes = accept.split(',') const acceptTypes = accept.split(',')
@ -171,19 +192,15 @@ export class TransformQueryBase {
} }
} }
} }
if (ua) { // Fallback
if (supportsAvif(ua)) {
this.format!.name = 'avif'
return
}
if (supportsWebP(ua)) {
this.format!.name = 'webp'
return
}
}
this.format!.name = 'jpeg' this.format!.name = 'jpeg'
} }
toString(): string {
const data = flatten(this) as Record<string, any>
return new URLSearchParams(sortObjectByKeys(data)).toString()
}
get hash(): string { get hash(): string {
return sha3(this.toString()) return sha3(this.toString())
} }

View File

@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify'
export function init(App: FastifyInstance) { export function init(App: FastifyInstance) {
App.register(require('under-pressure')) App.register(require('under-pressure'))
App.register(require('fastify-caching')) App.register(require('@fastify/caching'))
App.register(require('fastify-compress'), { global: true }) App.register(require('@fastify/compress'), { global: true })
App.register(require('fastify-cors'), { origin: '*' }) App.register(require('@fastify/cors'), { origin: '*' })
} }

View File

@ -1,5 +1,4 @@
import fastify from 'fastify' import fastify from 'fastify'
import { Config, init as initConfig } from './config' import { Config, init as initConfig } from './config'
import { init as initRoutes } from './controllers' import { init as initRoutes } from './controllers'
import { init as initHooks } from './fastify/hooks' import { init as initHooks } from './fastify/hooks'

View File

@ -1,71 +0,0 @@
import Avif from 'caniuse-db/features-json/avif.json'
import WebP from 'caniuse-db/features-json/webp.json'
import DeviceDetector from 'device-detector-js'
const detector = new DeviceDetector()
function findLowestSupportedVersion(stat: Record<string, string>): number | null {
const entries = Object.entries(stat).sort((a, b) => parseInt(a[0]) - parseInt(b[0]))
for (const [version, support] of entries) {
if (support.startsWith('y') || support.startsWith('a')) {
return parseInt(version)
}
}
return null
}
const BrowserMappings = {
'Internet Explorer': 'ie',
'Microsoft Edge': 'edge',
Firefox: 'firefox',
Chrome: 'chrome',
Safari: 'safari',
Opera: 'opera',
'Mobile Safari': 'ios_saf',
'Opera Mini': 'op_mini',
'Android Browser': 'android',
'Chrome Mobile': 'and_chr',
'Firefox Mobile': 'and_ff',
'UC Browser': 'and_uc',
'Samsung Browser': 'samsung',
'QQ Browser': 'and_qq',
}
function matchBrowserToStat(browser: DeviceDetector.DeviceDetectorResult): string {
if (browser.os!.name === 'iOS') {
return 'ios_saf'
}
if (browser.client!.name in BrowserMappings) {
return BrowserMappings[browser.client!.name as keyof typeof BrowserMappings]
}
throw new Error('Could not determine mapping for browser')
}
function match(feature: typeof Avif | typeof WebP, ua: string): boolean {
const browser = detector.parse(ua)
if (!browser.client || !browser.os) {
throw new Error('Could not parse browser')
}
const stats = feature.stats[matchBrowserToStat(browser) as keyof typeof feature.stats]
const lowestSupported = findLowestSupportedVersion(stats)
if (lowestSupported === null) {
return false
}
return lowestSupported <= parseInt(browser.client.version)
}
export function supportsAvif(ua: string): boolean {
try {
return match(Avif, ua)
} catch {
return false
}
}
export function supportsWebP(ua: string): boolean {
try {
return match(WebP, ua)
} catch {
return false
}
}