├── .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 |
--------------------------------------------------------------------------------