@liria24/og-image 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ import { readFileSync, readdirSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ import { defineConfig } from 'nitro'
5
+
6
+ export default defineConfig({
7
+ compatibilityDate: '2026-06-03',
8
+
9
+ preset: 'cloudflare_module',
10
+
11
+ devServer: {
12
+ port: 4000,
13
+ },
14
+
15
+ serverDir: true,
16
+
17
+ typescript: {
18
+ generateRuntimeConfigTypes: true,
19
+ generateTsConfig: true,
20
+ tsConfig: {
21
+ compilerOptions: {
22
+ baseUrl: null,
23
+ noEmit: true,
24
+ },
25
+ },
26
+ },
27
+
28
+ noExternals: ['takumi-js', '@takumi-rs/wasm', '@takumi-rs/helpers'],
29
+
30
+ cloudflare: {
31
+ deployConfig: true,
32
+ nodeCompat: true,
33
+ wrangler: {
34
+ name: 'og-image',
35
+ observability: {
36
+ enabled: true,
37
+ head_sampling_rate: 1,
38
+ },
39
+ kv_namespaces: [
40
+ {
41
+ binding: 'OG_IMAGE_CACHE',
42
+ id: 'e678f8e834784ea8b457786c695ded19',
43
+ },
44
+ ],
45
+ },
46
+ },
47
+
48
+ storage: {
49
+ 'og-image': {
50
+ driver: 'cloudflare-kv-binding',
51
+ binding: 'OG_IMAGE_CACHE',
52
+ },
53
+ },
54
+ devStorage: {
55
+ 'og-image': {
56
+ driver: 'fs-lite',
57
+ base: './.data/dev-storage/og-image',
58
+ },
59
+ },
60
+
61
+ imports: {
62
+ dirs: ['./server/utils'],
63
+ },
64
+
65
+ alias: {
66
+ '@src': fileURLToPath(new URL('./src', import.meta.url)),
67
+ },
68
+
69
+ virtual: {
70
+ '#images': () => {
71
+ const assetsDir = fileURLToPath(new URL('./server/assets', import.meta.url))
72
+ const entries = readdirSync(assetsDir)
73
+ .filter((file) => file.endsWith('.svg'))
74
+ .map((file) => {
75
+ const key = file.replace(/\.svg$/, '')
76
+ const svgPath = fileURLToPath(
77
+ new URL(`./server/assets/${file}`, import.meta.url),
78
+ )
79
+ const svg = readFileSync(svgPath, 'utf8')
80
+ return [
81
+ JSON.stringify(key),
82
+ `{ src: ${JSON.stringify(key)}, svg: ${JSON.stringify(svg)} }`,
83
+ ].join(': ')
84
+ })
85
+ return `export const images = { ${entries.join(', ')} }`
86
+ },
87
+ '#presets': () => {
88
+ const presetsDir = fileURLToPath(new URL('./server/presets', import.meta.url))
89
+ const names = readdirSync(presetsDir)
90
+ .filter((f) => f.endsWith('.ts'))
91
+ .map((f) => f.replace(/\.ts$/, ''))
92
+ const imports = names
93
+ .map((name, i) =>
94
+ [
95
+ `import _preset${i} from '${fileURLToPath(new URL(`./server/presets/${name}.ts`, import.meta.url)).replace(/\\/g, '/')}'`,
96
+ ].join('\n'),
97
+ )
98
+ .join('\n')
99
+ const helper = `import { withPresetId as _withPresetId } from '${fileURLToPath(new URL('./server/utils/definePreset.ts', import.meta.url)).replace(/\\/g, '/')}'`
100
+ const exports = `export const allPresets = [${names.map((name, i) => `_withPresetId(_preset${i}, ${JSON.stringify(name)})`).join(', ')}]`
101
+ return [helper, imports, exports].join('\n')
102
+ },
103
+ },
104
+ })
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'oxfmt'
2
+
3
+ export default defineConfig({
4
+ tabWidth: 4,
5
+ semi: false,
6
+ singleQuote: true,
7
+ printWidth: 100,
8
+ sortTailwindcss: {
9
+ functions: ['cn'],
10
+ },
11
+ sortImports: {},
12
+ sortPackageJson: {},
13
+ ignorePatterns: [],
14
+ overrides: [{ files: ['**/*.yml', '**/*.yaml', '**/*.md'], options: { tabWidth: 2 } }],
15
+ })
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'oxlint'
2
+
3
+ export default defineConfig({
4
+ plugins: ['import'],
5
+ categories: {
6
+ correctness: 'error',
7
+ },
8
+ options: {
9
+ typeAware: true,
10
+ },
11
+ })
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@liria24/og-image",
3
+ "version": "1.0.0",
4
+ "description": "OG image generation server",
5
+ "bugs": {
6
+ "url": "https://github.com/liria24/og-image/issues"
7
+ },
8
+ "license": "MIT",
9
+ "author": "Liria",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/liria24/og-image.git"
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "import": "./dist/client.mjs",
18
+ "types": "./dist/client.d.mts"
19
+ }
20
+ },
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.org/"
24
+ },
25
+ "scripts": {
26
+ "prepare": "nitro prepare",
27
+ "build:client": "tsdown",
28
+ "build": "bun run build:client && nitro build",
29
+ "dev": "nitro dev",
30
+ "preview": "wrangler --cwd .output dev",
31
+ "test": "vitest run",
32
+ "typecheck": "tsc --noEmit",
33
+ "lint": "oxlint",
34
+ "lint:fix": "oxlint --fix",
35
+ "fmt": "oxfmt",
36
+ "fmt:check": "oxfmt --check",
37
+ "check": "bun run prepare && bun run typecheck && bun run lint && bun run fmt:check && bun run build && wrangler --cwd .output deploy --dry-run",
38
+ "deploy": "bun run check && wrangler --cwd .output deploy",
39
+ "prepublish": "bun run check"
40
+ },
41
+ "dependencies": {
42
+ "ofetch": "^2.0.0-alpha.3"
43
+ },
44
+ "devDependencies": {
45
+ "@cloudflare/workers-types": "^4.20260608.1",
46
+ "@types/node": "^25.9.2",
47
+ "bumpp": "^11.1.0",
48
+ "http-status-codes": "^2.3.0",
49
+ "nitro": "3.0.260603-beta",
50
+ "oxfmt": "^0.54.0",
51
+ "oxlint": "^1.69.0",
52
+ "oxlint-tsgolint": "^0.23.0",
53
+ "takumi-js": "^1.7.0",
54
+ "taze": "^19.14.1",
55
+ "tsdown": "^0.22.2",
56
+ "typescript": "6.0.3",
57
+ "valibot": "^1.4.1",
58
+ "vitest": "^4.1.8",
59
+ "wrangler": "^4.98.0"
60
+ }
61
+ }
@@ -0,0 +1,5 @@
1
+ <svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor">
2
+ <g transform="matrix(0.0954084,0,0,0.0954084,-22.4782,-27.0578)">
3
+ <path d="M250.788,674.839C247.64,656.695 246,638.038 246,619C246,439.628 391.628,294 571,294C750.372,294 896,439.628 896,619C896,716.926 852.596,804.795 783.993,864.401L784.337,536.301C784.407,470.062 744.541,410.313 683.35,384.948C622.16,359.583 551.714,373.605 504.9,420.467L250.788,674.839ZM657.735,932.278C630.12,939.917 601.033,944 571,944C459.358,944 360.788,887.586 302.267,801.733L583.769,520.231C596.161,507.839 614.797,504.132 630.988,510.839C647.179,517.545 657.735,533.344 657.735,550.869L657.735,932.278Z" />
4
+ </g>
5
+ </svg>
@@ -0,0 +1,9 @@
1
+ import { consola } from 'consola'
2
+ import { defineHandler } from 'nitro'
3
+
4
+ export default defineHandler((event) => {
5
+ if (import.meta.dev)
6
+ consola
7
+ .withTag(`Request ${new Date().toLocaleTimeString()}`)
8
+ .info(`${event.req.method.toUpperCase()}: ${event.url.pathname}`)
9
+ })
@@ -0,0 +1,97 @@
1
+ import * as v from 'valibot'
2
+
3
+ import { images } from '#images'
4
+
5
+ const footerLogo = defineSvgImage(images.avatio!, {
6
+ src: 'avatio-footer-logo',
7
+ color: '#18181b',
8
+ width: 80,
9
+ height: 80,
10
+ })
11
+
12
+ export default definePreset({
13
+ version: 'v1',
14
+ props: v.object({
15
+ title: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
16
+ description: v.optional(v.pipe(v.string(), v.trim(), v.maxLength(240))),
17
+ }),
18
+ fonts: [
19
+ { family: 'Geist', options: { weight: '100..900' } },
20
+ { family: 'Noto Sans JP', options: { weight: '100..900' } },
21
+ ],
22
+ persistentImages: [footerLogo.image],
23
+ fontText: ({ title, description }) => [title, description],
24
+ content: ({ title, description }) => ({
25
+ type: 'container',
26
+ style: {
27
+ width: '100%',
28
+ height: '100%',
29
+ display: 'flex',
30
+ padding: '24px',
31
+ backgroundColor: '#b7b7c0',
32
+ color: '#18181b',
33
+ fontFamily: 'Geist, Noto Sans JP',
34
+ },
35
+ children: [
36
+ {
37
+ type: 'container',
38
+ style: {
39
+ flex: 1,
40
+ display: 'flex',
41
+ flexDirection: 'column',
42
+ justifyContent: 'space-between',
43
+ padding: '72px',
44
+ borderRadius: '36px',
45
+ backgroundColor: '#ffffff',
46
+ },
47
+ children: [
48
+ {
49
+ type: 'container',
50
+ style: {
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ gap: '24px',
54
+ },
55
+ children: [
56
+ {
57
+ type: 'text',
58
+ text: title,
59
+ style: {
60
+ fontSize: '68px',
61
+ fontWeight: 800,
62
+ lineHeight: 1.12,
63
+ letterSpacing: '0px',
64
+ maxWidth: '960px',
65
+ },
66
+ },
67
+ ...(description
68
+ ? [
69
+ {
70
+ type: 'text' as const,
71
+ text: description,
72
+ style: {
73
+ fontSize: '34px',
74
+ fontWeight: 500,
75
+ lineHeight: 1.45,
76
+ color: '#71717b',
77
+ maxWidth: '900px',
78
+ },
79
+ },
80
+ ]
81
+ : []),
82
+ ],
83
+ },
84
+ {
85
+ type: 'container',
86
+ style: {
87
+ display: 'flex',
88
+ alignItems: 'center',
89
+ justifyContent: 'flex-end',
90
+ },
91
+ children: [footerLogo.node],
92
+ },
93
+ ],
94
+ },
95
+ ],
96
+ }),
97
+ })
@@ -0,0 +1,3 @@
1
+ import { defineHandler } from 'nitro'
2
+
3
+ export default defineHandler(() => 'ok')
@@ -0,0 +1,49 @@
1
+ import { defineHandler } from 'nitro'
2
+ import { useStorage } from 'nitro/storage'
3
+ import * as v from 'valibot'
4
+
5
+ const cleanupKeyPrefixes = ['descriptor:', 'png:', 'failed:'] as const
6
+
7
+ const request = {
8
+ params: v.object({
9
+ imageId: v.pipe(
10
+ v.string(),
11
+ v.regex(/^[a-f0-9]{64}\.png$/),
12
+ v.transform((v) => v.replace(/\.png$/i, '')),
13
+ ),
14
+ }),
15
+ body: v.object({
16
+ secret: v.literal(process.env.OG_IMAGE_SECRET ?? ''),
17
+ }),
18
+ }
19
+
20
+ export default defineHandler(async (event) => {
21
+ await validateBody(event, request.body)
22
+ const { imageId } = await validateParams(event, request.params)
23
+
24
+ try {
25
+ const storage = useStorage('og-image')
26
+ const keys = cleanupKeyPrefixes.map((prefix) => `${prefix}${imageId}`)
27
+ const existingKeys = (
28
+ await Promise.all(
29
+ keys.map(async (key) => ((await storage.hasItem(key)) ? key : undefined)),
30
+ )
31
+ ).filter((key): key is string => key !== undefined)
32
+
33
+ await Promise.all(existingKeys.map((key) => storage.removeItem(key)))
34
+
35
+ console.info('Image cleaned up', { imageId, deleted: existingKeys.length })
36
+
37
+ event.res.headers.set('cache-control', 'no-store')
38
+ return {
39
+ imageId,
40
+ deleted: existingKeys.length,
41
+ }
42
+ } catch (error: unknown) {
43
+ console.error('Failed to cleanup image', {
44
+ imageId,
45
+ error: error instanceof Error ? error.message : String(error),
46
+ })
47
+ throw serverError.internalServerError()
48
+ }
49
+ })
@@ -0,0 +1,69 @@
1
+ import { defineHandler } from 'nitro'
2
+ import { useStorage } from 'nitro/storage'
3
+ import * as v from 'valibot'
4
+
5
+ const PNG_TTL_SECONDS = 60 * 60 * 24 * 30 // 30 days
6
+ const FAILED_TTL_SECONDS = 60 * 5 // 5 minutes
7
+ const SUCCESS_CACHE_CONTROL = 'public, max-age=31536000, immutable'
8
+
9
+ const request = {
10
+ params: v.object({
11
+ imageId: v.pipe(
12
+ v.string(),
13
+ v.regex(/^[a-f0-9]{64}\.png$/),
14
+ v.transform((v) => v.replace(/\.png$/i, '')),
15
+ ),
16
+ }),
17
+ }
18
+
19
+ export default defineHandler(async (event) => {
20
+ const { imageId } = await validateParams(event, request.params)
21
+
22
+ const storage = useStorage('og-image')
23
+
24
+ try {
25
+ const cachedPng = await storage.getItemRaw<ArrayBuffer>(`png:${imageId}`)
26
+ if (cachedPng) {
27
+ event.res.headers.set('content-type', 'image/png')
28
+ event.res.headers.set('cache-control', SUCCESS_CACHE_CONTROL)
29
+ return Uint8Array.from(pngBytes(cachedPng)).buffer
30
+ }
31
+
32
+ const failed = await storage.getItem(`failed:${imageId}`)
33
+ if (failed) throw serverError.notFound()
34
+
35
+ const d = await storage.getItem(`descriptor:${imageId}`)
36
+ const parsed = typeof d === 'string' ? JSON.parse(d) : d
37
+ const result = v.safeParse(ogImageDescriptorBaseSchema, parsed)
38
+ if (!result.success) throw serverError.notFound()
39
+
40
+ const preset = getPreset(result.output)
41
+
42
+ const propsResult = v.safeParse(preset.props, result.output.props)
43
+ if (!propsResult.success) throw serverError.notFound()
44
+ const descriptor = { ...result.output, props: propsResult.output }
45
+
46
+ const renderStart = Date.now()
47
+ const png = await withRenderTimeout(descriptor, renderDescriptor)
48
+ await storage.setItemRaw(`png:${imageId}`, pngBytes(png), { ttl: PNG_TTL_SECONDS })
49
+
50
+ console.info('Image rendered on demand', {
51
+ imageId,
52
+ preset: descriptor.slug,
53
+ durationMs: Date.now() - renderStart,
54
+ })
55
+
56
+ event.res.headers.set('content-type', 'image/png')
57
+ event.res.headers.set('cache-control', SUCCESS_CACHE_CONTROL)
58
+ return Uint8Array.from(pngBytes(png)).buffer
59
+ } catch (error: unknown) {
60
+ console.error('On-demand render failed', {
61
+ imageId,
62
+ error: error instanceof Error ? error.message : String(error),
63
+ })
64
+
65
+ await storage.setItem(`failed:${imageId}`, '1', { ttl: FAILED_TTL_SECONDS })
66
+
67
+ throw serverError.internalServerError()
68
+ }
69
+ })
@@ -0,0 +1,136 @@
1
+ import { defineHandler } from 'nitro'
2
+ import { useStorage } from 'nitro/storage'
3
+ import * as v from 'valibot'
4
+
5
+ import { allPresets } from '#presets'
6
+
7
+ const PNG_TTL_SECONDS = 60 * 60 * 24 * 30 // 30 days
8
+ const FAILED_TTL_SECONDS = 60 * 5 // 5 minutes
9
+
10
+ const request = {
11
+ params: v.object({
12
+ slug: v.union([...allPresets.map((preset) => v.literal(preset.slug))]),
13
+ version: v.string(),
14
+ }),
15
+ body: v.object({
16
+ secret: v.literal(process.env.OG_IMAGE_SECRET ?? ''),
17
+ props: v.object({
18
+ title: v.string(),
19
+ description: v.string(),
20
+ }),
21
+ }),
22
+ }
23
+
24
+ type CanonicalValue =
25
+ | string
26
+ | number
27
+ | boolean
28
+ | null
29
+ | CanonicalValue[]
30
+ | { [key: string]: CanonicalValue }
31
+
32
+ const canonicalize = (value: unknown): CanonicalValue => {
33
+ if (value === null) return null
34
+ if (Array.isArray(value)) return value.map((item) => canonicalize(item))
35
+ if (typeof value === 'object')
36
+ return Object.fromEntries(
37
+ Object.entries(value as Record<string, unknown>)
38
+ .filter(([, entry]) => entry !== undefined)
39
+ .sort(([a], [b]) => a.localeCompare(b))
40
+ .map(([key, entry]) => [key, canonicalize(entry)]),
41
+ )
42
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
43
+ return value
44
+ return null
45
+ }
46
+
47
+ export default defineHandler(async (event) => {
48
+ const { slug, version } = await validateParams(event, request.params)
49
+ const { props } = await validateBody(event, request.body)
50
+
51
+ const preset = getPreset({ slug, version })
52
+
53
+ const propsResult = v.safeParse(preset.props, props)
54
+ if (!propsResult.success) throw serverError.badRequest()
55
+
56
+ const descriptor: OgImageDescriptor = {
57
+ slug: preset.slug,
58
+ version: preset.version,
59
+ props: propsResult.output,
60
+ }
61
+ const imageId = await sha256Hex(
62
+ JSON.stringify(
63
+ canonicalize({
64
+ slug: descriptor.slug,
65
+ version: descriptor.version,
66
+ props: descriptor.props,
67
+ }),
68
+ ),
69
+ )
70
+
71
+ const storage = useStorage('og-image')
72
+
73
+ const existingDescriptor = await storage.getItem(`descriptor:${imageId}`)
74
+
75
+ if (!existingDescriptor)
76
+ try {
77
+ await storage.setItem(
78
+ `descriptor:${imageId}`,
79
+ JSON.stringify(
80
+ canonicalize({
81
+ slug: descriptor.slug,
82
+ version: descriptor.version,
83
+ props: descriptor.props,
84
+ }),
85
+ ),
86
+ )
87
+
88
+ const start = Date.now()
89
+ event.waitUntil(
90
+ (async () => {
91
+ try {
92
+ const png = await withRenderTimeout(descriptor, renderDescriptor)
93
+ await storage.setItemRaw(`png:${imageId}`, pngBytes(png), {
94
+ ttl: PNG_TTL_SECONDS,
95
+ })
96
+ console.info('Image rendered', {
97
+ imageId,
98
+ preset: descriptor.slug,
99
+ durationMs: Date.now() - start,
100
+ })
101
+ } catch (error: unknown) {
102
+ console.error('Background render failed', {
103
+ imageId,
104
+ preset: descriptor.slug,
105
+ durationMs: Date.now() - start,
106
+ error: error instanceof Error ? error.message : String(error),
107
+ })
108
+ await storage.setItem(`failed:${imageId}`, '1', { ttl: FAILED_TTL_SECONDS })
109
+ }
110
+ })(),
111
+ )
112
+
113
+ console.info('Image issued', {
114
+ imageId,
115
+ preset: descriptor.slug,
116
+ props: descriptor.props,
117
+ })
118
+ } catch (error: unknown) {
119
+ console.error('Failed to issue image', {
120
+ imageId,
121
+ error: error instanceof Error ? error.message : String(error),
122
+ })
123
+ throw serverError.internalServerError()
124
+ }
125
+ else {
126
+ console.info('Image already exists', {
127
+ imageId,
128
+ preset: descriptor.slug,
129
+ })
130
+ }
131
+
132
+ event.res.status = 202
133
+ return {
134
+ path: `/images/${imageId}.png`,
135
+ }
136
+ })
@@ -0,0 +1,40 @@
1
+ import { defineHandler } from 'nitro'
2
+ import { useStorage } from 'nitro/storage'
3
+ import * as v from 'valibot'
4
+
5
+ const cleanupKeyPrefixes = ['descriptor:', 'png:', 'failed:'] as const
6
+
7
+ const request = {
8
+ body: v.object({
9
+ secret: v.literal(process.env.OG_IMAGE_SECRET ?? ''),
10
+ }),
11
+ }
12
+
13
+ export default defineHandler(async (event) => {
14
+ await validateBody(event, request.body)
15
+
16
+ try {
17
+ const storage = useStorage('og-image')
18
+ const keys = [
19
+ ...new Set(
20
+ (
21
+ await Promise.all(cleanupKeyPrefixes.map((prefix) => storage.getKeys(prefix)))
22
+ ).flat(),
23
+ ),
24
+ ]
25
+
26
+ await Promise.all(keys.map((key) => storage.removeItem(key)))
27
+
28
+ console.info('Images cleaned up', { deleted: keys.length })
29
+
30
+ event.res.headers.set('cache-control', 'no-store')
31
+ return {
32
+ deleted: keys.length,
33
+ }
34
+ } catch (error: unknown) {
35
+ console.error('Failed to cleanup images', {
36
+ error: error instanceof Error ? error.message : String(error),
37
+ })
38
+ throw serverError.internalServerError()
39
+ }
40
+ })
@@ -0,0 +1,5 @@
1
+ import { defineHandler } from 'nitro'
2
+
3
+ export default defineHandler(async () => {
4
+ return 'POST to `/images/[preset]/[version]`'
5
+ })