From 63c93e6ecf070fd3b5f0d6d28cb24999af009178 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 18 May 2026 18:52:57 +0300 Subject: [PATCH 1/3] Fix error reporting terminating when Sentry fails in social shares API endpoint --- src/pages/__tests__/sw.spec.ts | 27 +++++ .../api/_utils/sentry/__tests__/index.spec.ts | 28 +++++ src/pages/api/_utils/sentry/index.ts | 36 +++--- src/pages/sw.js.ts | 108 ++++++++++++++++++ 4 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 src/pages/__tests__/sw.spec.ts create mode 100644 src/pages/sw.js.ts diff --git a/src/pages/__tests__/sw.spec.ts b/src/pages/__tests__/sw.spec.ts new file mode 100644 index 000000000..123976155 --- /dev/null +++ b/src/pages/__tests__/sw.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { GET, buildServiceWorkerScript, prerender } from '../sw.js' + +describe('/sw.js route', () => { + it('is prerendered for production builds', () => { + expect(prerender).toBe(true) + }) + + it('returns a JavaScript service worker response', async () => { + const response = await GET({} as never) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/javascript; charset=utf-8') + expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate') + expect(response.headers.get('Service-Worker-Allowed')).toBe('/') + + const body = await response.text() + expect(body).toContain("self.addEventListener('install'") + expect(body).toContain("self.addEventListener('fetch'") + expect(body).toContain("const OFFLINE_URL = '/offline'") + }) + + it('builds a stable script payload', () => { + expect(buildServiceWorkerScript()).toContain('webstackbuilders-offline-v1') + expect(buildServiceWorkerScript()).toContain('webstackbuilders-images-v1') + }) +}) \ No newline at end of file diff --git a/src/pages/api/_utils/sentry/__tests__/index.spec.ts b/src/pages/api/_utils/sentry/__tests__/index.spec.ts index 71ee9a265..93df507b4 100644 --- a/src/pages/api/_utils/sentry/__tests__/index.spec.ts +++ b/src/pages/api/_utils/sentry/__tests__/index.spec.ts @@ -9,6 +9,7 @@ const envMocks = vi.hoisted(() => ({ getSentryDsn: vi.fn(() => 'https://public@example.ingest.sentry.io/1'), getPackageRelease: vi.fn(() => 'pkg@1.0.0'), })) +const consoleErrorMock = vi.hoisted(() => vi.fn()) vi.mock('@sentry/astro', () => ({ init: sentryInitMock, @@ -16,12 +17,21 @@ vi.mock('@sentry/astro', () => ({ vi.mock('@pages/api/_utils/environment', () => envMocks) +vi.stubGlobal('console', { + ...console, + error: consoleErrorMock, +}) + describe('ensureApiSentry', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() envMocks.isProd.mockReset() envMocks.isProd.mockReturnValue(false) + envMocks.getSentryDsn.mockReset() + envMocks.getSentryDsn.mockReturnValue('https://public@example.ingest.sentry.io/1') + envMocks.getPackageRelease.mockReset() + envMocks.getPackageRelease.mockReturnValue('pkg@1.0.0') }) it('skips initialization outside production', async () => { @@ -64,4 +74,22 @@ describe('ensureApiSentry', () => { module.ensureApiSentry() expect(sentryInitMock).toHaveBeenCalledTimes(1) }) + + it('fails open when production Sentry config is unavailable', async () => { + envMocks.isProd.mockReturnValue(true) + envMocks.getSentryDsn.mockImplementation(() => { + throw new Error('missing dsn') + }) + + const module = await import('@pages/api/_utils/sentry') + + expect(sentryInitMock).not.toHaveBeenCalled() + expect(consoleErrorMock).toHaveBeenCalledWith( + '[api] failed to initialize Sentry; continuing without telemetry', + expect.any(Error) + ) + + module.ensureApiSentry() + expect(sentryInitMock).not.toHaveBeenCalled() + }) }) diff --git a/src/pages/api/_utils/sentry/index.ts b/src/pages/api/_utils/sentry/index.ts index d8f1c18be..b034286d0 100644 --- a/src/pages/api/_utils/sentry/index.ts +++ b/src/pages/api/_utils/sentry/index.ts @@ -8,23 +8,27 @@ export function ensureApiSentry(): void { return } - sentryInit({ - dsn: getSentryDsn(), - release: getPackageRelease(), - environment: 'production', - tracesSampleRate: 1.0, - sendDefaultPii: false, - attachStacktrace: true, - maxBreadcrumbs: 100, - beforeSend(event) { - if (!isProd()) { - return null - } - return event - }, - }) + try { + sentryInit({ + dsn: getSentryDsn(), + release: getPackageRelease(), + environment: 'production', + tracesSampleRate: 1.0, + sendDefaultPii: false, + attachStacktrace: true, + maxBreadcrumbs: 100, + beforeSend(event) { + if (!isProd()) { + return null + } + return event + }, + }) - initialized = true + initialized = true + } catch (error) { + console.error('[api] failed to initialize Sentry; continuing without telemetry', error) + } } ensureApiSentry() diff --git a/src/pages/sw.js.ts b/src/pages/sw.js.ts new file mode 100644 index 000000000..282b53124 --- /dev/null +++ b/src/pages/sw.js.ts @@ -0,0 +1,108 @@ +import type { APIRoute } from 'astro' + +export const prerender = true + +const OFFLINE_CACHE = 'webstackbuilders-offline-v1' +const ASSET_CACHE = 'webstackbuilders-assets-v1' +const IMAGE_CACHE = 'webstackbuilders-images-v1' +const OFFLINE_URL = '/offline' + +export const buildServiceWorkerScript = (): string => { + return [ + `const OFFLINE_CACHE = '${OFFLINE_CACHE}'`, + `const ASSET_CACHE = '${ASSET_CACHE}'`, + `const IMAGE_CACHE = '${IMAGE_CACHE}'`, + `const OFFLINE_URL = '${OFFLINE_URL}'`, + '', + "self.addEventListener('install', event => {", + ' event.waitUntil(', + " caches.open(OFFLINE_CACHE).then(cache => cache.add(OFFLINE_URL)).catch(() => undefined)", + ' )', + ' self.skipWaiting()', + '})', + '', + "self.addEventListener('activate', event => {", + ' event.waitUntil(self.clients.claim())', + '})', + '', + 'const cacheAsset = async (cacheName, request, response) => {', + ' if (!response || !response.ok) {', + ' return response', + ' }', + '', + ' const cache = await caches.open(cacheName)', + ' await cache.put(request, response.clone())', + ' return response', + '}', + '', + 'const staleWhileRevalidate = async request => {', + ' const cache = await caches.open(ASSET_CACHE)', + ' const cached = await cache.match(request)', + ' const network = fetch(request)', + ' .then(response => cacheAsset(ASSET_CACHE, request, response))', + ' .catch(() => undefined)', + '', + ' if (cached) {', + ' void network', + ' return cached', + ' }', + '', + ' return network || fetch(request)', + '}', + '', + 'const cacheFirst = async request => {', + ' const cache = await caches.open(IMAGE_CACHE)', + ' const cached = await cache.match(request)', + ' if (cached) {', + ' return cached', + ' }', + '', + ' const response = await fetch(request)', + ' return cacheAsset(IMAGE_CACHE, request, response)', + '}', + '', + 'const handleNavigation = async request => {', + ' try {', + ' return await fetch(request)', + ' } catch {', + ' const cachedOffline = await caches.match(OFFLINE_URL)', + ' if (cachedOffline) {', + ' return cachedOffline', + ' }', + '', + " return new Response('Offline', { status: 503, statusText: 'Offline' })", + ' }', + '}', + '', + "self.addEventListener('fetch', event => {", + ' const { request } = event', + " if (request.method !== 'GET') {", + ' return', + ' }', + '', + " if (request.mode === 'navigate') {", + ' event.respondWith(handleNavigation(request))', + ' return', + ' }', + '', + " if (request.destination === 'style' || request.destination === 'script') {", + ' event.respondWith(staleWhileRevalidate(request))', + ' return', + ' }', + '', + " if (request.destination === 'image') {", + ' event.respondWith(cacheFirst(request))', + ' }', + '})', + ].join('\n') +} + +export const GET: APIRoute = () => { + return new Response(buildServiceWorkerScript(), { + headers: { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Service-Worker-Allowed': '/', + }, + }) +} \ No newline at end of file From 96da4d0735687e9c718af2368d30e8b36e7614bb Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 18 May 2026 19:46:32 +0300 Subject: [PATCH 2/3] Fix codeql issue in test file --- src/pages/__tests__/sw.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/__tests__/sw.spec.ts b/src/pages/__tests__/sw.spec.ts index 123976155..159a3eae9 100644 --- a/src/pages/__tests__/sw.spec.ts +++ b/src/pages/__tests__/sw.spec.ts @@ -7,7 +7,7 @@ describe('/sw.js route', () => { }) it('returns a JavaScript service worker response', async () => { - const response = await GET({} as never) + const response = await GET() expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toBe('application/javascript; charset=utf-8') From 87510a3081349494c81ab93b630167ee84b85b5b Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 18 May 2026 19:52:25 +0300 Subject: [PATCH 3/3] Fix param mismatch in unit test case --- src/pages/__tests__/sw.spec.ts | 2 +- src/pages/sw.js.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/__tests__/sw.spec.ts b/src/pages/__tests__/sw.spec.ts index 159a3eae9..b38bda95a 100644 --- a/src/pages/__tests__/sw.spec.ts +++ b/src/pages/__tests__/sw.spec.ts @@ -7,7 +7,7 @@ describe('/sw.js route', () => { }) it('returns a JavaScript service worker response', async () => { - const response = await GET() + const response = await GET({} as Parameters[0]) expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toBe('application/javascript; charset=utf-8') diff --git a/src/pages/sw.js.ts b/src/pages/sw.js.ts index 282b53124..4924c050f 100644 --- a/src/pages/sw.js.ts +++ b/src/pages/sw.js.ts @@ -97,7 +97,7 @@ export const buildServiceWorkerScript = (): string => { ].join('\n') } -export const GET: APIRoute = () => { +export const GET: APIRoute = _context => { return new Response(buildServiceWorkerScript(), { headers: { 'Content-Type': 'application/javascript; charset=utf-8',