Skip to content

Commit 34d9452

Browse files
authored
feat: Support for Server-Side PNG Rasterization (/api/streak/png) (JhaSourav07#1205)
## Description — What does this PR do? Which issue does it fix? This PR implements server-side PNG Rasterization by introducing a new endpoint at `/api/streak/png` that returns a PNG buffer image instead of an SVG. It leverages the `@resvg/resvg-js` library for accurate headless SVG-to-PNG conversion without relying on browser rendering. Fixes JhaSourav07#980 ## Pillar — Which contribution pillar does this fall under? - [x] 🚀 New Feature - [ ] 🐛 Bug Fix - [ ] 🎨 New Theme Design - [ ] 📐 Geometric SVG Improvement - [ ] ⏱️ Timezone Logic Optimization - [ ] 🧹 Other (Bug fix, refactoring, docs) ## Checklist — Have you ticked off the quality checklist? - [x] I have read the CONTRIBUTING.md guidelines. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [x] I have kept the scope of my changes strictly related to the issue.
2 parents c79f38e + ba018cd commit 34d9452

5 files changed

Lines changed: 360 additions & 1 deletion

File tree

app/api/streak/png/route.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { GET } from './route';
4+
import { GET as getStreakSvg } from '../route';
5+
import { NextResponse } from 'next/server';
6+
7+
// Mock the core route
8+
vi.mock('../route', () => ({
9+
GET: vi.fn(),
10+
}));
11+
12+
// Mock resvg-js
13+
vi.mock('@resvg/resvg-js', () => {
14+
return {
15+
Resvg: class {
16+
constructor() {
17+
if ((global as any).throwResvgError) {
18+
throw new Error('Resvg crash');
19+
}
20+
}
21+
render() {
22+
return {
23+
asPng: () => Buffer.from('mock-png-data'),
24+
};
25+
}
26+
},
27+
};
28+
});
29+
30+
describe('PNG Route', () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks();
33+
});
34+
35+
it('converts SVG to PNG successfully', async () => {
36+
const mockRequest = new Request('http://localhost:3000/api/streak/png?user=testuser');
37+
38+
// Setup mock SVG response
39+
const mockSvgResponse = new NextResponse('<svg>test</svg>', {
40+
status: 200,
41+
headers: {
42+
'Content-Type': 'image/svg+xml',
43+
'Cache-Control': 'public, max-age=3600',
44+
},
45+
});
46+
vi.mocked(getStreakSvg).mockResolvedValue(mockSvgResponse);
47+
48+
const response = await GET(mockRequest);
49+
50+
expect(response.status).toBe(200);
51+
expect(response.headers.get('Content-Type')).toBe('image/png');
52+
expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600');
53+
54+
// Check if body contains buffer
55+
const buffer = Buffer.from(await response.arrayBuffer());
56+
expect(buffer.toString()).toBe('mock-png-data');
57+
});
58+
59+
it('returns errors from the base route directly', async () => {
60+
const mockRequest = new Request('http://localhost:3000/api/streak/png');
61+
62+
const mockErrorResponse = NextResponse.json({ error: 'Invalid parameters' }, { status: 400 });
63+
vi.mocked(getStreakSvg).mockResolvedValue(mockErrorResponse);
64+
65+
const response = await GET(mockRequest);
66+
67+
expect(response.status).toBe(400);
68+
const data = await response.json();
69+
expect(data.error).toBe('Invalid parameters');
70+
});
71+
72+
it('handles SVG conversion errors', async () => {
73+
const mockRequest = new Request('http://localhost:3000/api/streak/png?user=testuser');
74+
75+
const mockSvgResponse = new NextResponse('<svg>invalid</svg>', {
76+
status: 200,
77+
headers: {
78+
'Content-Type': 'image/svg+xml',
79+
},
80+
});
81+
vi.mocked(getStreakSvg).mockResolvedValue(mockSvgResponse);
82+
83+
// Force an error in Resvg
84+
(global as any).throwResvgError = true;
85+
86+
const response = await GET(mockRequest);
87+
88+
(global as any).throwResvgError = false;
89+
90+
expect(response.status).toBe(500);
91+
const data = await response.json();
92+
expect(data.error).toBe('Failed to convert SVG to PNG');
93+
});
94+
});

app/api/streak/png/route.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { GET as getStreakSvg } from '../route';
2+
import { Resvg } from '@resvg/resvg-js';
3+
import { NextResponse } from 'next/server';
4+
5+
export async function GET(request: Request) {
6+
// Call the original endpoint which returns the SVG text
7+
const response = await getStreakSvg(request);
8+
9+
if (!response.ok || response.headers.get('Content-Type') !== 'image/svg+xml') {
10+
// Return errors as is
11+
return response;
12+
}
13+
14+
const svgText = await response.text();
15+
16+
try {
17+
const resvg = new Resvg(svgText, {
18+
font: {
19+
loadSystemFonts: true,
20+
},
21+
fitTo: {
22+
mode: 'original',
23+
},
24+
});
25+
26+
const pngData = resvg.render();
27+
const pngBuffer = pngData.asPng();
28+
29+
// Preserve the original cache headers
30+
const cacheControl = response.headers.get('Cache-Control');
31+
32+
const headers = new Headers();
33+
headers.set('Content-Type', 'image/png');
34+
if (cacheControl) {
35+
headers.set('Cache-Control', cacheControl);
36+
}
37+
38+
return new NextResponse(pngBuffer as unknown as BodyInit, {
39+
status: 200,
40+
headers,
41+
});
42+
} catch (err) {
43+
return NextResponse.json(
44+
{ error: 'Failed to convert SVG to PNG', details: String(err) },
45+
{ status: 500 }
46+
);
47+
}
48+
}

next.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { NextConfig } from 'next';
33
const nextConfig: NextConfig = {
44
// Prevent Turbopack from bundling next/og through its shared module context,
55
// which causes the "Next.js package not found" HMR panic on dynamic routes.
6-
serverExternalPackages: ['next/og'],
6+
serverExternalPackages: ['next/og', '@resvg/resvg-js'],
77
// Allow the local network IP to access dev resources without cross-origin warnings
88
allowedDevOrigins: ['172.31.128.1'],
99
devIndicators: false,

package-lock.json

Lines changed: 216 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"*.{md,css,json,yml,yaml}": "prettier --write"
2525
},
2626
"dependencies": {
27+
"@resvg/resvg-js": "^2.6.2",
2728
"@tailwindcss/postcss": "^4.2.2",
2829
"@vercel/analytics": "^1.6.1",
2930
"framer-motion": "^12.38.0",

0 commit comments

Comments
 (0)