Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/pages/__tests__/sw.spec.ts
Original file line number Diff line number Diff line change
@@ -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 Parameters<typeof GET>[0])

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')
})
})
28 changes: 28 additions & 0 deletions src/pages/api/_utils/sentry/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,29 @@ 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,
}))

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 () => {
Expand Down Expand Up @@ -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()
})
})
36 changes: 20 additions & 16 deletions src/pages/api/_utils/sentry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
108 changes: 108 additions & 0 deletions src/pages/sw.js.ts
Original file line number Diff line number Diff line change
@@ -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 = _context => {
return new Response(buildServiceWorkerScript(), {
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Service-Worker-Allowed': '/',
},
})
}
Loading