Skip to content

Commit d81f5e4

Browse files
committed
feat: add /api/streak/png server-side rasterization
1 parent 0e99f30 commit d81f5e4

4 files changed

Lines changed: 359 additions & 0 deletions

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, {
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+
}

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)