Skip to content

Commit 63c93e6

Browse files
committed
Fix error reporting terminating when Sentry fails in social shares API endpoint
1 parent 8f6de71 commit 63c93e6

4 files changed

Lines changed: 183 additions & 16 deletions

File tree

src/pages/__tests__/sw.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { GET, buildServiceWorkerScript, prerender } from '../sw.js'
3+
4+
describe('/sw.js route', () => {
5+
it('is prerendered for production builds', () => {
6+
expect(prerender).toBe(true)
7+
})
8+
9+
it('returns a JavaScript service worker response', async () => {
10+
const response = await GET({} as never)
11+
12+
expect(response.status).toBe(200)
13+
expect(response.headers.get('Content-Type')).toBe('application/javascript; charset=utf-8')
14+
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate')
15+
expect(response.headers.get('Service-Worker-Allowed')).toBe('/')
16+
17+
const body = await response.text()
18+
expect(body).toContain("self.addEventListener('install'")
19+
expect(body).toContain("self.addEventListener('fetch'")
20+
expect(body).toContain("const OFFLINE_URL = '/offline'")
21+
})
22+
23+
it('builds a stable script payload', () => {
24+
expect(buildServiceWorkerScript()).toContain('webstackbuilders-offline-v1')
25+
expect(buildServiceWorkerScript()).toContain('webstackbuilders-images-v1')
26+
})
27+
})

src/pages/api/_utils/sentry/__tests__/index.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,29 @@ const envMocks = vi.hoisted(() => ({
99
getSentryDsn: vi.fn(() => 'https://public@example.ingest.sentry.io/1'),
1010
getPackageRelease: vi.fn(() => 'pkg@1.0.0'),
1111
}))
12+
const consoleErrorMock = vi.hoisted(() => vi.fn())
1213

1314
vi.mock('@sentry/astro', () => ({
1415
init: sentryInitMock,
1516
}))
1617

1718
vi.mock('@pages/api/_utils/environment', () => envMocks)
1819

20+
vi.stubGlobal('console', {
21+
...console,
22+
error: consoleErrorMock,
23+
})
24+
1925
describe('ensureApiSentry', () => {
2026
beforeEach(() => {
2127
vi.resetModules()
2228
vi.clearAllMocks()
2329
envMocks.isProd.mockReset()
2430
envMocks.isProd.mockReturnValue(false)
31+
envMocks.getSentryDsn.mockReset()
32+
envMocks.getSentryDsn.mockReturnValue('https://public@example.ingest.sentry.io/1')
33+
envMocks.getPackageRelease.mockReset()
34+
envMocks.getPackageRelease.mockReturnValue('pkg@1.0.0')
2535
})
2636

2737
it('skips initialization outside production', async () => {
@@ -64,4 +74,22 @@ describe('ensureApiSentry', () => {
6474
module.ensureApiSentry()
6575
expect(sentryInitMock).toHaveBeenCalledTimes(1)
6676
})
77+
78+
it('fails open when production Sentry config is unavailable', async () => {
79+
envMocks.isProd.mockReturnValue(true)
80+
envMocks.getSentryDsn.mockImplementation(() => {
81+
throw new Error('missing dsn')
82+
})
83+
84+
const module = await import('@pages/api/_utils/sentry')
85+
86+
expect(sentryInitMock).not.toHaveBeenCalled()
87+
expect(consoleErrorMock).toHaveBeenCalledWith(
88+
'[api] failed to initialize Sentry; continuing without telemetry',
89+
expect.any(Error)
90+
)
91+
92+
module.ensureApiSentry()
93+
expect(sentryInitMock).not.toHaveBeenCalled()
94+
})
6795
})

src/pages/api/_utils/sentry/index.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,27 @@ export function ensureApiSentry(): void {
88
return
99
}
1010

11-
sentryInit({
12-
dsn: getSentryDsn(),
13-
release: getPackageRelease(),
14-
environment: 'production',
15-
tracesSampleRate: 1.0,
16-
sendDefaultPii: false,
17-
attachStacktrace: true,
18-
maxBreadcrumbs: 100,
19-
beforeSend(event) {
20-
if (!isProd()) {
21-
return null
22-
}
23-
return event
24-
},
25-
})
11+
try {
12+
sentryInit({
13+
dsn: getSentryDsn(),
14+
release: getPackageRelease(),
15+
environment: 'production',
16+
tracesSampleRate: 1.0,
17+
sendDefaultPii: false,
18+
attachStacktrace: true,
19+
maxBreadcrumbs: 100,
20+
beforeSend(event) {
21+
if (!isProd()) {
22+
return null
23+
}
24+
return event
25+
},
26+
})
2627

27-
initialized = true
28+
initialized = true
29+
} catch (error) {
30+
console.error('[api] failed to initialize Sentry; continuing without telemetry', error)
31+
}
2832
}
2933

3034
ensureApiSentry()

src/pages/sw.js.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { APIRoute } from 'astro'
2+
3+
export const prerender = true
4+
5+
const OFFLINE_CACHE = 'webstackbuilders-offline-v1'
6+
const ASSET_CACHE = 'webstackbuilders-assets-v1'
7+
const IMAGE_CACHE = 'webstackbuilders-images-v1'
8+
const OFFLINE_URL = '/offline'
9+
10+
export const buildServiceWorkerScript = (): string => {
11+
return [
12+
`const OFFLINE_CACHE = '${OFFLINE_CACHE}'`,
13+
`const ASSET_CACHE = '${ASSET_CACHE}'`,
14+
`const IMAGE_CACHE = '${IMAGE_CACHE}'`,
15+
`const OFFLINE_URL = '${OFFLINE_URL}'`,
16+
'',
17+
"self.addEventListener('install', event => {",
18+
' event.waitUntil(',
19+
" caches.open(OFFLINE_CACHE).then(cache => cache.add(OFFLINE_URL)).catch(() => undefined)",
20+
' )',
21+
' self.skipWaiting()',
22+
'})',
23+
'',
24+
"self.addEventListener('activate', event => {",
25+
' event.waitUntil(self.clients.claim())',
26+
'})',
27+
'',
28+
'const cacheAsset = async (cacheName, request, response) => {',
29+
' if (!response || !response.ok) {',
30+
' return response',
31+
' }',
32+
'',
33+
' const cache = await caches.open(cacheName)',
34+
' await cache.put(request, response.clone())',
35+
' return response',
36+
'}',
37+
'',
38+
'const staleWhileRevalidate = async request => {',
39+
' const cache = await caches.open(ASSET_CACHE)',
40+
' const cached = await cache.match(request)',
41+
' const network = fetch(request)',
42+
' .then(response => cacheAsset(ASSET_CACHE, request, response))',
43+
' .catch(() => undefined)',
44+
'',
45+
' if (cached) {',
46+
' void network',
47+
' return cached',
48+
' }',
49+
'',
50+
' return network || fetch(request)',
51+
'}',
52+
'',
53+
'const cacheFirst = async request => {',
54+
' const cache = await caches.open(IMAGE_CACHE)',
55+
' const cached = await cache.match(request)',
56+
' if (cached) {',
57+
' return cached',
58+
' }',
59+
'',
60+
' const response = await fetch(request)',
61+
' return cacheAsset(IMAGE_CACHE, request, response)',
62+
'}',
63+
'',
64+
'const handleNavigation = async request => {',
65+
' try {',
66+
' return await fetch(request)',
67+
' } catch {',
68+
' const cachedOffline = await caches.match(OFFLINE_URL)',
69+
' if (cachedOffline) {',
70+
' return cachedOffline',
71+
' }',
72+
'',
73+
" return new Response('Offline', { status: 503, statusText: 'Offline' })",
74+
' }',
75+
'}',
76+
'',
77+
"self.addEventListener('fetch', event => {",
78+
' const { request } = event',
79+
" if (request.method !== 'GET') {",
80+
' return',
81+
' }',
82+
'',
83+
" if (request.mode === 'navigate') {",
84+
' event.respondWith(handleNavigation(request))',
85+
' return',
86+
' }',
87+
'',
88+
" if (request.destination === 'style' || request.destination === 'script') {",
89+
' event.respondWith(staleWhileRevalidate(request))',
90+
' return',
91+
' }',
92+
'',
93+
" if (request.destination === 'image') {",
94+
' event.respondWith(cacheFirst(request))',
95+
' }',
96+
'})',
97+
].join('\n')
98+
}
99+
100+
export const GET: APIRoute = () => {
101+
return new Response(buildServiceWorkerScript(), {
102+
headers: {
103+
'Content-Type': 'application/javascript; charset=utf-8',
104+
'Cache-Control': 'no-cache, no-store, must-revalidate',
105+
'Service-Worker-Allowed': '/',
106+
},
107+
})
108+
}

0 commit comments

Comments
 (0)