├── .github └── workflows │ ├── docker.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── package-lock.json ├── package.json ├── scripts └── build-fonts.ts ├── src ├── app.ts ├── config.ts ├── handler │ ├── avatar.ts │ └── schema.ts ├── routes │ ├── collection.ts │ ├── style.ts │ └── version.ts ├── server.ts ├── types.ts └── utils │ ├── fonts.ts │ ├── query-string.ts │ ├── unicode.ts │ └── versions.ts ├── tests └── http.test.js ├── tsconfig.json └── versions ├── 5.x ├── index.d.ts ├── index.js └── package.json ├── 6.x ├── index.d.ts ├── index.js └── package.json ├── 7.x ├── index.d.ts ├── index.js └── package.json ├── 8.x ├── index.d.ts ├── index.js └── package.json └── 9.x ├── index.d.ts ├── index.js └── package.json /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | test: 10 | uses: dicebear/api/.github/workflows/test.yml@3.x 11 | push_to_registries: 12 | needs: test 13 | name: Push Docker image to multiple registries 14 | runs-on: ubuntu-latest 15 | permissions: 16 | packages: write 17 | contents: read 18 | steps: 19 | - name: Check out the repo 20 | uses: actions/checkout@v4 21 | 22 | - name: Log in to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_PASSWORD }} 27 | 28 | - name: Log in to the Container registry 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ghcr.io 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Extract metadata (tags, labels) for Docker 36 | id: meta 37 | uses: docker/metadata-action@v5 38 | with: 39 | images: | 40 | dicebear/api 41 | ghcr.io/${{ github.repository }} 42 | tags: | 43 | type=semver,pattern={{version}} 44 | type=semver,pattern={{major}}.{{minor}} 45 | type=semver,pattern={{major}} 46 | type=raw,value=latest,enable={{is_default_branch}} 47 | 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v3 50 | 51 | - name: Set up Docker Buildx 52 | uses: docker/setup-buildx-action@v3 53 | 54 | - name: Build and push Docker images 55 | uses: docker/build-push-action@v6 56 | with: 57 | platforms: linux/amd64,linux/arm64 58 | context: . 59 | push: true 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '1.x' 7 | - '2.x' 8 | pull_request: 9 | branches: 10 | - '1.x' 11 | - '2.x' 12 | workflow_call: {} 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [20, 22, 24] 21 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - run: npm ci 30 | - run: npm run build 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | .pnpm-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | 134 | # font build 135 | fonts 136 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.wordBasedSuggestions": "off" 3 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | contact@florian-koerner.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-slim AS build 2 | WORKDIR /app 3 | COPY . . 4 | RUN npm ci 5 | RUN npm run build 6 | 7 | FROM node:24-slim AS prod 8 | EXPOSE 3000 9 | WORKDIR /app 10 | COPY --from=build /app/dist /app/dist 11 | COPY --from=build /app/fonts /app/fonts 12 | COPY versions /app/versions 13 | COPY LICENSE /app/LICENSE 14 | COPY package.json /app/package.json 15 | COPY package-lock.json /app/package-lock.json 16 | RUN npm ci --production 17 | 18 | CMD ["node", "./dist/server.js"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Florian Körner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

DiceBear API

2 | 3 | This is the source code for the [DiceBear API](https://dicebear.com/how-to-use/http-api). It's built on [Fastify](https://fastify.io/). 4 | Learn how to set up your own instance of the API in the [documentation](https://dicebear.com/guides/host-the-http-api-yourself). 5 | 6 | [Playground](https://dicebear.com/playground/) | 7 | [Documentation](https://dicebear.com/guides/host-the-http-api-yourself/) 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you have discovered a vulnerability, please email contact@florian-koerner.com privately instead of opening an issue. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dicebear/api", 3 | "private": true, 4 | "license": "MIT", 5 | "author": "Florian Körner ", 6 | "type": "module", 7 | "scripts": { 8 | "predev": "tsx ./scripts/build-fonts.ts", 9 | "dev": "tsx --watch ./src/server.ts", 10 | "prebuild": "tsx ./scripts/build-fonts.ts", 11 | "build": "tsc --build", 12 | "start": "node ./dist/server.js", 13 | "test": "node --test tests/*.js" 14 | }, 15 | "workspaces": [ 16 | "./versions/*" 17 | ], 18 | "dependencies": { 19 | "@dicebear/api-5": "*", 20 | "@dicebear/api-6": "*", 21 | "@dicebear/api-7": "*", 22 | "@dicebear/api-8": "*", 23 | "@dicebear/api-9": "*", 24 | "@dicebear/converter": "^9.2.3", 25 | "@fastify/cors": "^11.0.1", 26 | "change-case": "^5.4.4", 27 | "fastify": "^5.4.0", 28 | "qs": "^6.14.0" 29 | }, 30 | "devDependencies": { 31 | "@fontsource/noto-sans": "^5.2.7", 32 | "@fontsource/noto-sans-jp": "^5.2.5", 33 | "@fontsource/noto-sans-kr": "^5.2.5", 34 | "@fontsource/noto-sans-sc": "^5.2.6", 35 | "@fontsource/noto-sans-thai": "^5.2.5", 36 | "@tsconfig/node20": "^20.1.5", 37 | "@types/json-schema": "^7.0.15", 38 | "@types/node": "^22.15.30", 39 | "@types/qs": "^6.14.0", 40 | "@woff2/woff2-rs": "^1.0.1", 41 | "prettier": "^3.5.3", 42 | "tsx": "^4.19.4", 43 | "typescript": "^5.8.3" 44 | }, 45 | "engines": { 46 | "node": ">=20.10" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /scripts/build-fonts.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'module'; 2 | import path from 'path'; 3 | import woff2Rs from '@woff2/woff2-rs'; 4 | import { promises as fs, existsSync } from 'fs'; 5 | import { fileURLToPath } from 'node:url'; 6 | 7 | type Font = { font: string; ranges: [number, number][] }; 8 | 9 | const require = createRequire(import.meta.url); 10 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); 11 | 12 | const TARGET_DIR = path.join(__dirname, '..', 'fonts'); 13 | const FONT_NAMESPACE = '@fontsource'; 14 | const FONT_PACKAGES = [ 15 | 'noto-sans', 16 | 'noto-sans-thai', 17 | 'noto-sans-jp', 18 | 'noto-sans-kr', 19 | 'noto-sans-sc', 20 | ]; 21 | 22 | if (!existsSync(TARGET_DIR)) { 23 | await fs.mkdir(TARGET_DIR, { recursive: true }); 24 | } 25 | 26 | const fonts: Font[] = []; 27 | 28 | for (const fontPackage of FONT_PACKAGES) { 29 | const fontPathTargetDir = path.join(TARGET_DIR, fontPackage); 30 | 31 | if (!existsSync(fontPathTargetDir)) { 32 | await fs.mkdir(fontPathTargetDir, { recursive: true }); 33 | } 34 | 35 | await fs.copyFile( 36 | require 37 | .resolve(`${FONT_NAMESPACE}/${fontPackage}/index.css`) 38 | .replace('index.css', 'LICENSE'), 39 | path.join(fontPathTargetDir, 'LICENSE'), 40 | ); 41 | 42 | const unicodeMetadata: Record = ( 43 | await import(`${FONT_NAMESPACE}/${fontPackage}/unicode.json`, { 44 | with: { type: 'json' }, 45 | }) 46 | ).default; 47 | 48 | for (const [subset, ranges] of Object.entries(unicodeMetadata)) { 49 | const parsedRanges: [number, number][] = []; 50 | 51 | for (const range of ranges.split(',')) { 52 | if (range.includes('-')) { 53 | const [start, end] = range.split('-'); 54 | 55 | const parsedStart = parseInt(start.replace('U+', ''), 16); 56 | const parsedEnd = parseInt(end.replace('U+', ''), 16); 57 | 58 | parsedRanges.push([parsedStart, parsedEnd]); 59 | 60 | continue; 61 | } 62 | 63 | const parsedStart = parseInt(range.replace('U+', ''), 16); 64 | const parsedEnd = parsedStart; 65 | 66 | parsedRanges.push([parsedStart, parsedEnd]); 67 | } 68 | 69 | const subsetName = subset.replace(/[\[\]]/g, ''); 70 | 71 | const fontPathSource = require.resolve( 72 | `${FONT_NAMESPACE}/${fontPackage}/files/${fontPackage}-${subsetName}-400-normal.woff2`, 73 | ); 74 | 75 | const fontFileName = path.basename(fontPathSource, '.woff2') + '.ttf'; 76 | const fontPathTarget = path.join(fontPathTargetDir, fontFileName); 77 | 78 | const fontInputBuffer = await fs.readFile(require.resolve(fontPathSource)); 79 | const fontOutputBuffer = woff2Rs.decode(fontInputBuffer); 80 | 81 | await fs.writeFile(fontPathTarget, fontOutputBuffer); 82 | 83 | fonts.push({ 84 | font: `${fontPackage}/${fontFileName}`, 85 | ranges: parsedRanges, 86 | }); 87 | } 88 | } 89 | 90 | await fs.writeFile( 91 | path.join(TARGET_DIR, 'fonts.json'), 92 | JSON.stringify(fonts, null, 2), 93 | ); 94 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { config } from './config.js'; 2 | import fastify from 'fastify'; 3 | import cors from '@fastify/cors'; 4 | 5 | import { parseQueryString } from './utils/query-string.js'; 6 | import { versionRoutes } from './routes/version.js'; 7 | import { getVersions } from './utils/versions.js'; 8 | import { Font } from './types.js'; 9 | import { fileURLToPath } from 'url'; 10 | import { promises as fs } from 'fs'; 11 | import * as path from 'path'; 12 | 13 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); 14 | 15 | export const app = async () => { 16 | const app = fastify({ 17 | logger: config.logger, 18 | querystringParser: (str) => parseQueryString(str), 19 | ajv: { 20 | customOptions: { 21 | coerceTypes: 'array', 22 | removeAdditional: true, 23 | useDefaults: false, 24 | }, 25 | }, 26 | maxParamLength: 1024, 27 | }); 28 | 29 | const fonts = JSON.parse( 30 | await fs.readFile(path.join(__dirname, '../fonts/fonts.json'), 'utf-8') 31 | ) as Font[]; 32 | 33 | app.decorate('fonts', fonts); 34 | 35 | await app.register(cors); 36 | 37 | await app.register(versionRoutes, { versions: await getVersions() }); 38 | 39 | return app; 40 | }; 41 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './types.js'; 2 | 3 | export const config: Config = { 4 | port: Number(process.env.PORT ?? 3000), 5 | host: process.env.HOST ?? '0.0.0.0', 6 | logger: Boolean(Number(process.env.LOGGER) ?? 0), 7 | workers: Number(process.env.WORKERS ?? 1), 8 | png: { 9 | enabled: Boolean(Number(process.env.PNG ?? 1)), 10 | size: { 11 | min: Number(process.env.PNG_SIZE_MIN ?? 1), 12 | max: Number(process.env.PNG_SIZE_MAX ?? 256), 13 | default: Number(process.env.PNG_SIZE_DEFAULT ?? 128), 14 | }, 15 | exif: Boolean(Number(process.env.PNG_EXIF ?? 1)), 16 | }, 17 | jpeg: { 18 | enabled: Boolean(Number(process.env.JPEG ?? 1)), 19 | size: { 20 | min: Number(process.env.JPEG_SIZE_MIN ?? 1), 21 | max: Number(process.env.JPEG_SIZE_MAX ?? 256), 22 | default: Number(process.env.JPEG_SIZE_DEFAULT ?? 128), 23 | }, 24 | exif: Boolean(Number(process.env.JPEG_EXIF ?? 1)), 25 | }, 26 | webp: { 27 | enabled: Boolean(Number(process.env.WEBP ?? 1)), 28 | size: { 29 | min: Number(process.env.WEBP_SIZE_MIN ?? 1), 30 | max: Number(process.env.WEBP_SIZE_MAX ?? 256), 31 | default: Number(process.env.WEBP_SIZE_DEFAULT ?? 128), 32 | }, 33 | exif: Boolean(Number(process.env.WEBP_EXIF ?? 1)), 34 | }, 35 | avif: { 36 | enabled: Boolean(Number(process.env.AVIF ?? 1)), 37 | size: { 38 | min: Number(process.env.AVIF_SIZE_MIN ?? 1), 39 | max: Number(process.env.AVIF_SIZE_MAX ?? 256), 40 | default: Number(process.env.AVIF_SIZE_DEFAULT ?? 128), 41 | }, 42 | exif: Boolean(Number(process.env.AVIF_EXIF ?? 1)), 43 | }, 44 | json: { 45 | enabled: Boolean(Number(process.env.JSON ?? 1)), 46 | }, 47 | versions: process.env.VERSIONS?.split(',').map(Number) ?? [5, 6, 7, 8, 9], 48 | cacheControl: { 49 | avatar: Number(process.env.CACHE_CONTROL_AVATARS ?? 60 * 60 * 24 * 365), 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /src/handler/avatar.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; 2 | import type { Core } from '../types.js'; 3 | import { config } from '../config.js'; 4 | import { toJpeg, toPng, toWebp, toAvif } from '@dicebear/converter'; 5 | import { getRequiredFonts } from '../utils/fonts.js'; 6 | 7 | export type AvatarRequest = { 8 | Params: { 9 | format: 'svg' | 'png' | 'jpg' | 'jpeg' | 'webp' | 'avif' | 'json'; 10 | options?: Record; 11 | }; 12 | Querystring: Record; 13 | }; 14 | 15 | export function avatarHandler(app: FastifyInstance, core: Core, style: any) { 16 | return async ( 17 | request: FastifyRequest, 18 | reply: FastifyReply 19 | ) => { 20 | const options = request.query; 21 | 22 | // Validate Size for PNG Format 23 | if (request.params.format === 'png') { 24 | options['size'] = options['size'] 25 | ? Math.min( 26 | Math.max(options['size'], config.png.size.min), 27 | config.png.size.max 28 | ) 29 | : config.png.size.default; 30 | } 31 | 32 | // Validate Size for JPEG Format 33 | if (request.params.format === 'jpg' || request.params.format === 'jpeg') { 34 | options['size'] = options['size'] 35 | ? Math.min( 36 | Math.max(options['size'], config.jpeg.size.min), 37 | config.jpeg.size.max 38 | ) 39 | : config.jpeg.size.default; 40 | } 41 | 42 | // Validate Size for WebP Format 43 | if (request.params.format === 'webp') { 44 | options['size'] = options['size'] 45 | ? Math.min( 46 | Math.max(options['size'], config.webp.size.min), 47 | config.webp.size.max 48 | ) 49 | : config.webp.size.default; 50 | } 51 | 52 | // Validate Size for Avif Format 53 | if (request.params.format === 'avif') { 54 | options['size'] = options['size'] 55 | ? Math.min( 56 | Math.max(options['size'], config.avif.size.min), 57 | config.avif.size.max 58 | ) 59 | : config.avif.size.default; 60 | } 61 | 62 | // Define default seed 63 | options['seed'] = options['seed'] ?? ''; 64 | 65 | // Define filename 66 | reply.header( 67 | 'Content-Disposition', 68 | `inline; filename="avatar.${request.params.format}"` 69 | ); 70 | 71 | // Create avatar 72 | const avatar = core.createAvatar(style, options); 73 | 74 | reply.header('X-Robots-Tag', 'noindex'); 75 | reply.header('Cache-Control', `max-age=${config.cacheControl.avatar}`); 76 | 77 | switch (request.params.format) { 78 | case 'svg': 79 | reply.header('Content-Type', 'image/svg+xml'); 80 | 81 | return avatar.toString(); 82 | 83 | case 'png': 84 | reply.header('Content-Type', 'image/png'); 85 | 86 | const png = await toPng(avatar.toString(), { 87 | includeExif: config.png.exif, 88 | fonts: getRequiredFonts(avatar.toString(), app.fonts), 89 | }).toArrayBuffer(); 90 | 91 | return Buffer.from(png); 92 | 93 | case 'jpg': 94 | case 'jpeg': 95 | reply.header('Content-Type', 'image/jpeg'); 96 | 97 | const jpeg = await toJpeg(avatar.toString(), { 98 | includeExif: config.jpeg.exif, 99 | fonts: getRequiredFonts(avatar.toString(), app.fonts), 100 | }).toArrayBuffer(); 101 | 102 | return Buffer.from(jpeg); 103 | 104 | case 'webp': 105 | reply.header('Content-Type', 'image/webp'); 106 | 107 | const webp = await toWebp(avatar.toString(), { 108 | includeExif: config.webp.exif, 109 | fonts: getRequiredFonts(avatar.toString(), app.fonts), 110 | }).toArrayBuffer(); 111 | 112 | return Buffer.from(webp); 113 | 114 | case 'avif': 115 | reply.header('Content-Type', 'image/avif'); 116 | 117 | const avif = await toAvif(avatar.toString(), { 118 | includeExif: config.avif.exif, 119 | fonts: getRequiredFonts(avatar.toString(), app.fonts), 120 | }).toArrayBuffer(); 121 | 122 | return Buffer.from(avif); 123 | 124 | case 'json': 125 | reply.header('Content-Type', 'application/json'); 126 | 127 | return JSON.stringify(avatar.toJson()); 128 | } 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /src/handler/schema.ts: -------------------------------------------------------------------------------- 1 | import type { RouteHandlerMethod } from 'fastify'; 2 | import { JSONSchema7, JSONSchema7Definition } from 'json-schema'; 3 | 4 | export function schemaHandler(schema: JSONSchema7): RouteHandlerMethod { 5 | return (request, reply) => { 6 | reply.header('Content-Type', 'application/json'); 7 | 8 | return JSON.stringify(schema, undefined, 2); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/collection.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginCallback } from 'fastify'; 2 | import type { Version } from '../types.js'; 3 | import { kebabCase } from 'change-case'; 4 | import { styleRoutes } from './style.js'; 5 | 6 | type Options = { 7 | version: Version; 8 | }; 9 | 10 | export const collectionRoutes: FastifyPluginCallback = ( 11 | app, 12 | { version }, 13 | done 14 | ) => { 15 | for (const [prefix, style] of Object.entries(version.collection)) { 16 | app.register(styleRoutes, { 17 | prefix: `/${kebabCase(prefix)}`, 18 | core: version.core, 19 | style, 20 | }); 21 | } 22 | 23 | done(); 24 | }; 25 | -------------------------------------------------------------------------------- /src/routes/style.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginAsync, FastifyPluginCallback } from 'fastify'; 2 | import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; 3 | import type { Core } from '../types.js'; 4 | import { schemaHandler } from '../handler/schema.js'; 5 | import { parseQueryString } from '../utils/query-string.js'; 6 | import { AvatarRequest, avatarHandler } from '../handler/avatar.js'; 7 | import { config } from '../config.js'; 8 | 9 | type Options = { 10 | core: Core; 11 | style: any; 12 | }; 13 | 14 | const paramsSchema: JSONSchema7 = { 15 | $schema: 'http://json-schema.org/draft-07/schema#', 16 | type: 'object', 17 | properties: { 18 | format: { 19 | type: 'string', 20 | enum: [ 21 | 'svg', 22 | ...(config.png.enabled ? ['png'] : []), 23 | ...(config.jpeg.enabled ? ['jpg', 'jpeg'] : []), 24 | ...(config.webp.enabled ? ['webp'] : []), 25 | ...(config.avif.enabled ? ['avif'] : []), 26 | ...(config.json.enabled ? ['json'] : []), 27 | ], 28 | }, 29 | }, 30 | }; 31 | 32 | export const styleRoutes: FastifyPluginCallback = ( 33 | app, 34 | { core, style }, 35 | done, 36 | ) => { 37 | const optionsSchema: JSONSchema7 = { 38 | $schema: 'http://json-schema.org/draft-07/schema#', 39 | type: 'object', 40 | properties: { 41 | ...core.schema.properties, 42 | ...style.schema?.properties, 43 | }, 44 | }; 45 | 46 | app.route({ 47 | method: 'GET', 48 | url: '/schema.json', 49 | handler: schemaHandler(optionsSchema), 50 | }); 51 | 52 | app.route({ 53 | method: 'GET', 54 | url: '/:format', 55 | schema: { 56 | querystring: optionsSchema, 57 | params: paramsSchema, 58 | }, 59 | handler: avatarHandler(app, core, style), 60 | }); 61 | 62 | app.route({ 63 | method: 'GET', 64 | url: '/:format/:options', 65 | preValidation: async (request) => { 66 | if (typeof request.params.options === 'string') { 67 | request.query = parseQueryString(request.params.options); 68 | } 69 | }, 70 | schema: { 71 | querystring: optionsSchema, 72 | params: paramsSchema, 73 | }, 74 | handler: avatarHandler(app, core, style), 75 | }); 76 | 77 | done(); 78 | }; 79 | -------------------------------------------------------------------------------- /src/routes/version.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyPluginCallback } from 'fastify'; 2 | import type { Version } from '../types.js'; 3 | import { collectionRoutes } from './collection.js'; 4 | 5 | type Options = { 6 | versions: Record; 7 | }; 8 | 9 | export const versionRoutes: FastifyPluginCallback = ( 10 | app, 11 | { versions }, 12 | done 13 | ) => { 14 | for (const [prefix, version] of Object.entries(versions)) { 15 | app.register(collectionRoutes, { 16 | prefix: `/${prefix}`, 17 | version, 18 | }); 19 | } 20 | 21 | done(); 22 | }; 23 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import cluster from 'node:cluster'; 2 | import { config } from './config.js'; 3 | import { app } from './app.js'; 4 | 5 | const useCluster = config.workers > 1; 6 | 7 | if (cluster.isPrimary && useCluster) { 8 | for (let i = 0; i < config.workers; i++) { 9 | cluster.fork(); 10 | } 11 | 12 | cluster.on('exit', (worker, code, signal) => { 13 | console.log( 14 | `Worker ${worker.process.pid} died with code ${code} and signal ${signal}` 15 | ); 16 | 17 | // Fork a new worker 18 | cluster.fork(); 19 | }); 20 | } else { 21 | console.log(`Worker ${process.pid} started`); 22 | 23 | const server = await app(); 24 | 25 | server.listen( 26 | { 27 | port: config.port, 28 | host: config.host, 29 | }, 30 | (err) => { 31 | if (err) { 32 | server.log.error(err); 33 | process.exit(1); 34 | } 35 | 36 | console.info(`Server listening at http://${config.host}:${config.port}`); 37 | } 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7 } from 'json-schema'; 2 | 3 | declare module 'fastify' { 4 | interface FastifyInstance { 5 | fonts: Font[]; 6 | } 7 | } 8 | 9 | export type Core = { 10 | createAvatar: ( 11 | style: any, 12 | options?: any 13 | ) => { 14 | toString: () => string; 15 | toJson: () => { 16 | svg: string; 17 | extra: Record; 18 | }; 19 | }; 20 | schema: JSONSchema7; 21 | }; 22 | 23 | export type Version = { 24 | core: Core; 25 | collection: Record; 26 | }; 27 | 28 | export type Config = { 29 | port: number; 30 | host: string; 31 | logger: boolean; 32 | workers: number; 33 | versions: number[]; 34 | png: { 35 | enabled: boolean; 36 | size: { 37 | max: number; 38 | min: number; 39 | default: number; 40 | }; 41 | exif: boolean; 42 | }; 43 | jpeg: { 44 | enabled: boolean; 45 | size: { 46 | max: number; 47 | min: number; 48 | default: number; 49 | }; 50 | exif: boolean; 51 | }; 52 | webp: { 53 | enabled: boolean; 54 | size: { 55 | max: number; 56 | min: number; 57 | default: number; 58 | }; 59 | exif: boolean; 60 | }; 61 | avif: { 62 | enabled: boolean; 63 | size: { 64 | max: number; 65 | min: number; 66 | default: number; 67 | }; 68 | exif: boolean; 69 | }; 70 | json: { 71 | enabled: boolean; 72 | }; 73 | cacheControl: { 74 | avatar: number; 75 | }; 76 | }; 77 | 78 | export type Font = { 79 | font: string; 80 | ranges: [number, number][]; 81 | }; 82 | -------------------------------------------------------------------------------- /src/utils/fonts.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Font } from '../types.js'; 3 | import { isCharacterInUnicodeRange } from './unicode.js'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); 7 | 8 | export function getRequiredFonts(svg: string, fonts: Font[]): string[] { 9 | const textNodes = svg.matchAll(/(.*?)<\/text>/gs); 10 | const requiredFonts = new Set(); 11 | 12 | if (!textNodes) { 13 | return [...requiredFonts]; 14 | } 15 | 16 | for (const textNode of textNodes) { 17 | const text = textNode[1]; 18 | 19 | char: for (const char of text) { 20 | for (const font of fonts) { 21 | for (const range of font.ranges) { 22 | if (isCharacterInUnicodeRange(char, range)) { 23 | requiredFonts.add(path.join(__dirname, '../../fonts', font.font)); 24 | continue char; 25 | } 26 | } 27 | } 28 | } 29 | } 30 | 31 | return [...requiredFonts]; 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/query-string.ts: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | 3 | export function parseQueryString(str: string): Record { 4 | const result = Object.create(null); 5 | // @see https://github.com/dicebear/dicebear/issues/382 6 | const preparedStr = str.replaceAll('%2C', ','); 7 | const parsed = qs.parse(preparedStr, { 8 | comma: true, 9 | plainObjects: true, 10 | depth: 1, 11 | }); 12 | 13 | for (const key of Object.keys(parsed)) { 14 | let value = parsed[key]; 15 | 16 | // A seed could be parsed as an array due to commas. In this case convert back to a string. 17 | if (key === 'seed' && Array.isArray(value)) { 18 | value = value.join(','); 19 | } 20 | 21 | // Only add non-empty values 22 | if (Array.isArray(value)) { 23 | result[key] = value.filter((v) => v !== ''); 24 | } else if (value !== '') { 25 | result[key] = [value]; 26 | } 27 | } 28 | 29 | return result; 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/unicode.ts: -------------------------------------------------------------------------------- 1 | export function parseUnicodeRange(range: string): [number, number] { 2 | if (range.includes('-')) { 3 | const [start, end] = range.split('-'); 4 | 5 | const parsedStart = parseInt(start.replace('U+', ''), 16); 6 | const parsedEnd = parseInt(end.replace('U+', ''), 16); 7 | 8 | return [parsedStart, parsedEnd]; 9 | } 10 | 11 | const parsedStart = parseInt(range.replace('U+', ''), 16); 12 | const parsedEnd = parsedStart; 13 | 14 | return [parsedStart, parsedEnd]; 15 | } 16 | 17 | export function isCharacterInUnicodeRange( 18 | char: string, 19 | range: [number, number] 20 | ) { 21 | const charCode = char.charCodeAt(0); 22 | return charCode >= range[0] && charCode <= range[1]; 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/versions.ts: -------------------------------------------------------------------------------- 1 | import { Version } from '../types.js'; 2 | import { config } from '../config.js'; 3 | 4 | export async function getVersions(): Promise> { 5 | const versions: Record = {}; 6 | 7 | if (config.versions.includes(5)) { 8 | versions['5.x'] = await import('@dicebear/api-5'); 9 | } 10 | 11 | if (config.versions.includes(6)) { 12 | versions['6.x'] = await import('@dicebear/api-6'); 13 | } 14 | 15 | if (config.versions.includes(7)) { 16 | versions['7.x'] = await import('@dicebear/api-7'); 17 | } 18 | 19 | if (config.versions.includes(8)) { 20 | versions['8.x'] = await import('@dicebear/api-8'); 21 | } 22 | 23 | if (config.versions.includes(9)) { 24 | versions['9.x'] = await import('@dicebear/api-9'); 25 | } 26 | 27 | return versions; 28 | } 29 | -------------------------------------------------------------------------------- /tests/http.test.js: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import assert from 'node:assert/strict'; 3 | 4 | import { app } from '../dist/app.js'; 5 | 6 | for (let version of [5, 6, 7, 8, 9]) { 7 | const requests = [ 8 | { 9 | path: `/${version}.x/initials/svg`, 10 | status: 200, 11 | }, 12 | { 13 | path: `/${version}.x/initials/svg?size=10`, 14 | status: 200, 15 | }, 16 | { 17 | path: `/${version}.x/initials/svg?size=a`, 18 | status: 400, 19 | }, 20 | { 21 | path: `/${version}.x/initials/svg/size=10`, 22 | status: 200, 23 | }, 24 | { 25 | path: `/${version}.x/initials/svg/size=a`, 26 | status: 400, 27 | }, 28 | { 29 | path: `/${version}.x/initials/svg?backgroundColor=000000,ffffff`, 30 | status: 200, 31 | }, 32 | { 33 | path: `/${version}.x/initials/svg/backgroundColor=000000,ffffff`, 34 | status: 200, 35 | }, 36 | { 37 | path: `/${version}.x/initials/svg?backgroundColor=000000%2Cffffff`, 38 | status: 200, 39 | }, 40 | { 41 | path: `/${version}.x/initials/svg/backgroundColor=000000%2Cffffff`, 42 | status: 200, 43 | }, 44 | { 45 | path: `/${version}.x/initials/schema.json`, 46 | status: 200, 47 | }, 48 | ]; 49 | 50 | const server = app(); 51 | 52 | for (let { path, status } of requests) { 53 | test(path, async () => { 54 | const readyApp = await server; 55 | const response = await readyApp.inject({ 56 | method: 'GET', 57 | path, 58 | }); 59 | 60 | assert.equal(response.statusCode, status); 61 | }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "target": "ESNext", 6 | "moduleResolution": "NodeNext", 7 | "outDir": "dist" 8 | }, 9 | "include": ["src/"] 10 | } 11 | -------------------------------------------------------------------------------- /versions/5.x/index.d.ts: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/5.x/index.js: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/5.x/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dicebear/api-5", 3 | "private": true, 4 | "type": "module", 5 | "exports": { 6 | "default": "./index.js", 7 | "types": "./index.d.ts" 8 | }, 9 | "dependencies": { 10 | "@dicebear/collection": "^5.0.0", 11 | "@dicebear/core": "^5.0.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /versions/6.x/index.d.ts: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/6.x/index.js: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/6.x/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dicebear/api-6", 3 | "private": true, 4 | "type": "module", 5 | "exports": { 6 | "default": "./index.js", 7 | "types": "./index.d.ts" 8 | }, 9 | "dependencies": { 10 | "@dicebear/collection": "^6.0.0", 11 | "@dicebear/core": "^6.0.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /versions/7.x/index.d.ts: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/7.x/index.js: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/7.x/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dicebear/api-7", 3 | "private": true, 4 | "type": "module", 5 | "exports": { 6 | "default": "./index.js", 7 | "types": "./index.d.ts" 8 | }, 9 | "dependencies": { 10 | "@dicebear/collection": "^7.0.0", 11 | "@dicebear/core": "^7.0.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /versions/8.x/index.d.ts: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/8.x/index.js: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/8.x/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dicebear/api-8", 3 | "private": true, 4 | "type": "module", 5 | "exports": { 6 | "default": "./index.js", 7 | "types": "./index.d.ts" 8 | }, 9 | "dependencies": { 10 | "@dicebear/collection": "^8.0.0", 11 | "@dicebear/core": "^8.0.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /versions/9.x/index.d.ts: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/9.x/index.js: -------------------------------------------------------------------------------- 1 | export * as core from '@dicebear/core'; 2 | export * as collection from '@dicebear/collection'; 3 | -------------------------------------------------------------------------------- /versions/9.x/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dicebear/api-9", 3 | "private": true, 4 | "type": "module", 5 | "exports": { 6 | "default": "./index.js", 7 | "types": "./index.d.ts" 8 | }, 9 | "dependencies": { 10 | "@dicebear/collection": "^9.2.3", 11 | "@dicebear/core": "^9.2.3" 12 | } 13 | } 14 | --------------------------------------------------------------------------------