Skip to content

Commit 6cfbfbf

Browse files
committed
Keep prod health checks and route fallbacks from failing on stale API entries
Add a Bun health script that exercises top-level, dynamic, and representative video routes against one or more hosts so prod regressions are visible from a single command. Device pages now fall back to the bundled device list when the external API misses a slug, and orphaned tv slugs redirect to /benchmarks instead of returning a 500. Video fallback logic reuses the existing YouTube-to-listing builder so route reconstruction stays aligned with the current build logic. Constraint: The external API host can lag behind the frontend build and omit per-slug JSON files that public routes still expect Rejected: Import the generated video list directly | static/video-list.json is too large for a safe SSR fallback Rejected: Leave missing tv routes as 500s | a stale public URL should degrade to a useful redirect instead of breaking the request Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep route fallbacks tied to build-time artifacts from the same repo so frontend and fallback data stay in sync Tested: bun scripts/health http://127.0.0.1:4322; vitest ./test/prebuild/config-node.test.js ./test/prebuild/site-listings.test.js; pnpm run netlify-build Not-tested: live production deploy before push
1 parent 820e495 commit 6cfbfbf

7 files changed

Lines changed: 224 additions & 7 deletions

File tree

helpers/api/client.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ const defaultFetchMethod = async function (...args) {
2525
return axios(...args)
2626
.then( response => response.data )
2727
.catch( error => {
28-
console.error( error )
28+
if ( error?.response?.status !== 404 ) {
29+
console.error( error )
30+
}
31+
2932
throw error
3033
})
3134
}

helpers/build-video-list.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ const videoFeaturesApp = function (app, video) {
3333
return false
3434
}
3535

36+
export function makeVideoSlug ( title, videoId ) {
37+
return makeSlug( `${ title }-i-${ videoId }` )
38+
}
39+
3640
const generateVideoTags = function ( video ) {
3741
const tags = {
3842
'benchmark': {
@@ -129,7 +133,7 @@ const makeThumbnailData = function ( thumbnails, widthLimit = null ) {
129133
}
130134
}
131135

132-
async function handleFetchedVideo ( fetchedVideo, videoId, applist ) {
136+
export async function buildVideoListingFromFetchedVideo ( fetchedVideo, videoId, applist ) {
133137

134138
// Skip private videos
135139
if (fetchedVideo.title === 'Private video') return
@@ -138,7 +142,7 @@ async function handleFetchedVideo ( fetchedVideo, videoId, applist ) {
138142
if (fetchedVideo.title === 'Deleted video') return
139143

140144
// Build video slug
141-
const slug = makeSlug( `${fetchedVideo.title}-i-${videoId}` )
145+
const slug = makeVideoSlug( fetchedVideo.title, videoId )
142146

143147
const appLinks = []
144148
// Generate new tag set based on api data
@@ -200,8 +204,7 @@ export default async function ( applist ) {
200204
.withConcurrency(1000)
201205
.for( Object.entries( fetchedVideos ) )
202206
.process(async ( [ videoId, fetchedVideo ], index, pool ) => {
203-
const mappedVideo = await handleFetchedVideo ( fetchedVideo, videoId, applist )
204-
207+
const mappedVideo = await buildVideoListingFromFetchedVideo( fetchedVideo, videoId, applist )
205208
// Skip if this video is not an object
206209
if ( Object( mappedVideo ) !== mappedVideo ) return
207210

helpers/site-listings.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import fs from 'fs-extra'
2+
import path from 'path'
3+
import { fileURLToPath } from 'url'
4+
5+
import {
6+
buildVideoListingFromFetchedVideo,
7+
makeVideoSlug
8+
} from '~/helpers/build-video-list.js'
9+
import { youtubeVideoPath } from '~/helpers/api/youtube/build.js'
10+
11+
const currentModuleDirectory = path.dirname( fileURLToPath( import.meta.url ) )
12+
const appListPath = path.join( currentModuleDirectory, '../static/app-list.json' )
13+
const gameListPath = path.join( currentModuleDirectory, '../static/game-list.json' )
14+
const deviceListPath = path.join( currentModuleDirectory, '../static/device-list.json' )
15+
const trailingCommaPattern = /,\s*([\]}])/g
16+
17+
function parseGeneratedJsonFile ( filePath ) {
18+
const fileContents = fs.readFileSync( filePath, 'utf8' )
19+
20+
return JSON.parse( fileContents.replace( trailingCommaPattern, '$1' ) )
21+
}
22+
23+
export function getDeviceListingBySlug ( slug ) {
24+
const deviceList = parseGeneratedJsonFile( deviceListPath )
25+
26+
return deviceList.find( device => device.slug === slug ) || null
27+
}
28+
29+
function getAllVideoAppsList () {
30+
return [
31+
...parseGeneratedJsonFile( appListPath ),
32+
...parseGeneratedJsonFile( gameListPath )
33+
]
34+
}
35+
36+
export async function getVideoListingBySlug ( slug ) {
37+
const fetchedVideos = await fs.readJson( youtubeVideoPath )
38+
const allVideoAppsList = getAllVideoAppsList()
39+
40+
for ( const [ videoId, fetchedVideo ] of Object.entries( fetchedVideos ) ) {
41+
if ( makeVideoSlug( fetchedVideo.title, videoId ) !== slug ) continue
42+
43+
return await buildVideoListingFromFetchedVideo( fetchedVideo, videoId, allVideoAppsList )
44+
}
45+
46+
return null
47+
}

scripts/health

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env bun
2+
3+
const routeGroups = {
4+
topLevel: [
5+
'/',
6+
'/categories',
7+
'/devices',
8+
'/benchmarks',
9+
'/games',
10+
'/apple-silicon-app-test'
11+
],
12+
dynamic: [
13+
'/app/kicad-eda',
14+
'/app/spotify',
15+
'/formula/bash',
16+
'/kind/developer-tools',
17+
'/device/m1-imac',
18+
'/app/expressvpn/benchmarks'
19+
],
20+
video: [
21+
'/tv/apple-silicon-gaming-is-here',
22+
'/tv/install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i',
23+
'/tv/xamarin-and-visual-studio-on-apple-macbook-pro-13-m1-in-4k-i-rwpspmmlos',
24+
'/tv/watch-this-before-buying-apple-m1-macbook-for-xampp-or-apple-silicon-tests-in-4k-i-ebwwewsis8s'
25+
]
26+
}
27+
28+
function parseHosts(rawHosts) {
29+
const source = rawHosts && rawHosts.trim().length > 0
30+
? rawHosts
31+
: 'doesitarm.com'
32+
33+
return source
34+
.split(',')
35+
.map(host => host.trim())
36+
.filter(Boolean)
37+
.map(host => host.startsWith('http://') || host.startsWith('https://') ? host : `https://${host}`)
38+
}
39+
40+
function getPaths() {
41+
return Object.values(routeGroups).flat()
42+
}
43+
44+
function extractTitle(html) {
45+
const match = html.match(/<title>([^<]+)<\/title>/i)
46+
47+
return match ? match[1].trim() : ''
48+
}
49+
50+
async function runCheck(host, path) {
51+
const url = new URL(path, host)
52+
const response = await fetch(url, {
53+
redirect: 'follow',
54+
headers: {
55+
'user-agent': 'doesitarm-health-check'
56+
}
57+
})
58+
59+
const html = await response.text()
60+
const finalUrl = response.url
61+
62+
return {
63+
host: new URL(host).host,
64+
path,
65+
status: response.status,
66+
ok: response.ok,
67+
finalPath: new URL(finalUrl).pathname,
68+
title: extractTitle(html)
69+
}
70+
}
71+
72+
const hosts = parseHosts(process.argv[2] || '')
73+
const paths = getPaths()
74+
75+
console.log(`Checking ${paths.length} routes across ${hosts.length} host(s)`)
76+
77+
let hasFailures = false
78+
79+
for (const host of hosts) {
80+
console.log(`\nHost: ${new URL(host).host}`)
81+
82+
const results = await Promise.all(paths.map(path => runCheck(host, path)))
83+
84+
for (const result of results) {
85+
const statusLabel = result.ok ? 'PASS' : 'FAIL'
86+
const redirectSuffix = result.finalPath !== result.path ? ` -> ${result.finalPath}` : ''
87+
const titleSuffix = result.title.length > 0 ? ` | ${result.title}` : ''
88+
89+
console.log(`${statusLabel} ${result.status} ${result.path}${redirectSuffix}${titleSuffix}`)
90+
}
91+
92+
const failures = results.filter(result => !result.ok)
93+
94+
if (failures.length > 0) {
95+
hasFailures = true
96+
console.log(`Failures: ${failures.length}`)
97+
} else {
98+
console.log('Failures: 0')
99+
}
100+
}
101+
102+
if (hasFailures) {
103+
process.exit(1)
104+
}

src/pages/device/[...devicePath].astro

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
applyResponseDefaults
1111
} from '~/helpers/astro/request.js'
1212
import { deviceSupportsApp } from '~/helpers/devices.js'
13+
import { getDeviceListingBySlug } from '~/helpers/site-listings.js'
1314
1415
1516
import Layout from '../../layouts/default.astro'
@@ -36,7 +37,22 @@ if ( redirectResponse !== null ) {
3637
3738
applyResponseDefaults( Astro )
3839
39-
const device = await DoesItAPI.device( pathSlug ).get()
40+
let device
41+
42+
try {
43+
device = await DoesItAPI.device( pathSlug ).get()
44+
} catch ( error ) {
45+
if ( error?.response?.status !== 404 ) {
46+
throw error
47+
}
48+
49+
device = getDeviceListingBySlug( pathSlug )
50+
}
51+
52+
if ( device === null || typeof device === 'undefined' ) {
53+
return Astro.redirect( '/devices' )
54+
}
55+
4056
const rawAppPage = await DoesItAPI.kind( 'app' )( subSlug ).get()
4157
4258

src/pages/tv/[...videoPath].astro

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getVideoImages,
1212
ListingDetails
1313
} from '~/helpers/listing-page.js'
14+
import { getVideoListingBySlug } from '~/helpers/site-listings.js'
1415
import { getPathPartsFromAstroRequest } from '~/helpers/url.js'
1516
1617
import Layout from '~/src/layouts/default.astro'
@@ -39,7 +40,21 @@ const {
3940
// https://docs.astro.build/en/reference/api-reference/#astrorequests
4041
4142
// Request App data from API
42-
const tvListing = await DoesItAPI.tv( pathSlug ).get()
43+
let tvListing
44+
45+
try {
46+
tvListing = await DoesItAPI.tv( pathSlug ).get()
47+
} catch ( error ) {
48+
if ( error?.response?.status !== 404 ) {
49+
throw error
50+
}
51+
52+
tvListing = await getVideoListingBySlug( pathSlug )
53+
}
54+
55+
if ( tvListing === null || typeof tvListing === 'undefined' ) {
56+
return Astro.redirect( '/benchmarks' )
57+
}
4358
4459
const listingDetails = new ListingDetails( tvListing )
4560
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
getDeviceListingBySlug,
5+
getVideoListingBySlug
6+
} from '~/helpers/site-listings.js'
7+
8+
describe( 'site listing fallbacks', () => {
9+
it( 'loads known devices from the bundled device list', () => {
10+
expect( getDeviceListingBySlug( 'm1-imac' ) ).toMatchObject({
11+
name: 'M1 iMac',
12+
endpoint: '/device/m1-imac'
13+
})
14+
})
15+
16+
it( 'rebuilds known tv listings from the bundled YouTube source', async () => {
17+
await expect(
18+
getVideoListingBySlug( 'install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i' )
19+
).resolves.toMatchObject({
20+
endpoint: '/tv/install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i'
21+
})
22+
})
23+
24+
it( 'returns null for missing tv slugs', async () => {
25+
await expect(
26+
getVideoListingBySlug( 'apple-silicon-gaming-is-here' )
27+
).resolves.toBeNull()
28+
})
29+
})

0 commit comments

Comments
 (0)