mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-15 06:35:20 -07:00
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
This commit is contained in:
parent
820e495d2d
commit
6cfbfbf530
7 changed files with 224 additions and 7 deletions
|
|
@ -25,7 +25,10 @@ const defaultFetchMethod = async function (...args) {
|
|||
return axios(...args)
|
||||
.then( response => response.data )
|
||||
.catch( error => {
|
||||
if ( error?.response?.status !== 404 ) {
|
||||
console.error( error )
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ const videoFeaturesApp = function (app, video) {
|
|||
return false
|
||||
}
|
||||
|
||||
export function makeVideoSlug ( title, videoId ) {
|
||||
return makeSlug( `${ title }-i-${ videoId }` )
|
||||
}
|
||||
|
||||
const generateVideoTags = function ( video ) {
|
||||
const tags = {
|
||||
'benchmark': {
|
||||
|
|
@ -129,7 +133,7 @@ const makeThumbnailData = function ( thumbnails, widthLimit = null ) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleFetchedVideo ( fetchedVideo, videoId, applist ) {
|
||||
export async function buildVideoListingFromFetchedVideo ( fetchedVideo, videoId, applist ) {
|
||||
|
||||
// Skip private videos
|
||||
if (fetchedVideo.title === 'Private video') return
|
||||
|
|
@ -138,7 +142,7 @@ async function handleFetchedVideo ( fetchedVideo, videoId, applist ) {
|
|||
if (fetchedVideo.title === 'Deleted video') return
|
||||
|
||||
// Build video slug
|
||||
const slug = makeSlug( `${fetchedVideo.title}-i-${videoId}` )
|
||||
const slug = makeVideoSlug( fetchedVideo.title, videoId )
|
||||
|
||||
const appLinks = []
|
||||
// Generate new tag set based on api data
|
||||
|
|
@ -200,8 +204,7 @@ export default async function ( applist ) {
|
|||
.withConcurrency(1000)
|
||||
.for( Object.entries( fetchedVideos ) )
|
||||
.process(async ( [ videoId, fetchedVideo ], index, pool ) => {
|
||||
const mappedVideo = await handleFetchedVideo ( fetchedVideo, videoId, applist )
|
||||
|
||||
const mappedVideo = await buildVideoListingFromFetchedVideo( fetchedVideo, videoId, applist )
|
||||
// Skip if this video is not an object
|
||||
if ( Object( mappedVideo ) !== mappedVideo ) return
|
||||
|
||||
|
|
|
|||
47
helpers/site-listings.js
Normal file
47
helpers/site-listings.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import {
|
||||
buildVideoListingFromFetchedVideo,
|
||||
makeVideoSlug
|
||||
} from '~/helpers/build-video-list.js'
|
||||
import { youtubeVideoPath } from '~/helpers/api/youtube/build.js'
|
||||
|
||||
const currentModuleDirectory = path.dirname( fileURLToPath( import.meta.url ) )
|
||||
const appListPath = path.join( currentModuleDirectory, '../static/app-list.json' )
|
||||
const gameListPath = path.join( currentModuleDirectory, '../static/game-list.json' )
|
||||
const deviceListPath = path.join( currentModuleDirectory, '../static/device-list.json' )
|
||||
const trailingCommaPattern = /,\s*([\]}])/g
|
||||
|
||||
function parseGeneratedJsonFile ( filePath ) {
|
||||
const fileContents = fs.readFileSync( filePath, 'utf8' )
|
||||
|
||||
return JSON.parse( fileContents.replace( trailingCommaPattern, '$1' ) )
|
||||
}
|
||||
|
||||
export function getDeviceListingBySlug ( slug ) {
|
||||
const deviceList = parseGeneratedJsonFile( deviceListPath )
|
||||
|
||||
return deviceList.find( device => device.slug === slug ) || null
|
||||
}
|
||||
|
||||
function getAllVideoAppsList () {
|
||||
return [
|
||||
...parseGeneratedJsonFile( appListPath ),
|
||||
...parseGeneratedJsonFile( gameListPath )
|
||||
]
|
||||
}
|
||||
|
||||
export async function getVideoListingBySlug ( slug ) {
|
||||
const fetchedVideos = await fs.readJson( youtubeVideoPath )
|
||||
const allVideoAppsList = getAllVideoAppsList()
|
||||
|
||||
for ( const [ videoId, fetchedVideo ] of Object.entries( fetchedVideos ) ) {
|
||||
if ( makeVideoSlug( fetchedVideo.title, videoId ) !== slug ) continue
|
||||
|
||||
return await buildVideoListingFromFetchedVideo( fetchedVideo, videoId, allVideoAppsList )
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
104
scripts/health
Executable file
104
scripts/health
Executable file
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
const routeGroups = {
|
||||
topLevel: [
|
||||
'/',
|
||||
'/categories',
|
||||
'/devices',
|
||||
'/benchmarks',
|
||||
'/games',
|
||||
'/apple-silicon-app-test'
|
||||
],
|
||||
dynamic: [
|
||||
'/app/kicad-eda',
|
||||
'/app/spotify',
|
||||
'/formula/bash',
|
||||
'/kind/developer-tools',
|
||||
'/device/m1-imac',
|
||||
'/app/expressvpn/benchmarks'
|
||||
],
|
||||
video: [
|
||||
'/tv/apple-silicon-gaming-is-here',
|
||||
'/tv/install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i',
|
||||
'/tv/xamarin-and-visual-studio-on-apple-macbook-pro-13-m1-in-4k-i-rwpspmmlos',
|
||||
'/tv/watch-this-before-buying-apple-m1-macbook-for-xampp-or-apple-silicon-tests-in-4k-i-ebwwewsis8s'
|
||||
]
|
||||
}
|
||||
|
||||
function parseHosts(rawHosts) {
|
||||
const source = rawHosts && rawHosts.trim().length > 0
|
||||
? rawHosts
|
||||
: 'doesitarm.com'
|
||||
|
||||
return source
|
||||
.split(',')
|
||||
.map(host => host.trim())
|
||||
.filter(Boolean)
|
||||
.map(host => host.startsWith('http://') || host.startsWith('https://') ? host : `https://${host}`)
|
||||
}
|
||||
|
||||
function getPaths() {
|
||||
return Object.values(routeGroups).flat()
|
||||
}
|
||||
|
||||
function extractTitle(html) {
|
||||
const match = html.match(/<title>([^<]+)<\/title>/i)
|
||||
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
async function runCheck(host, path) {
|
||||
const url = new URL(path, host)
|
||||
const response = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
'user-agent': 'doesitarm-health-check'
|
||||
}
|
||||
})
|
||||
|
||||
const html = await response.text()
|
||||
const finalUrl = response.url
|
||||
|
||||
return {
|
||||
host: new URL(host).host,
|
||||
path,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
finalPath: new URL(finalUrl).pathname,
|
||||
title: extractTitle(html)
|
||||
}
|
||||
}
|
||||
|
||||
const hosts = parseHosts(process.argv[2] || '')
|
||||
const paths = getPaths()
|
||||
|
||||
console.log(`Checking ${paths.length} routes across ${hosts.length} host(s)`)
|
||||
|
||||
let hasFailures = false
|
||||
|
||||
for (const host of hosts) {
|
||||
console.log(`\nHost: ${new URL(host).host}`)
|
||||
|
||||
const results = await Promise.all(paths.map(path => runCheck(host, path)))
|
||||
|
||||
for (const result of results) {
|
||||
const statusLabel = result.ok ? 'PASS' : 'FAIL'
|
||||
const redirectSuffix = result.finalPath !== result.path ? ` -> ${result.finalPath}` : ''
|
||||
const titleSuffix = result.title.length > 0 ? ` | ${result.title}` : ''
|
||||
|
||||
console.log(`${statusLabel} ${result.status} ${result.path}${redirectSuffix}${titleSuffix}`)
|
||||
}
|
||||
|
||||
const failures = results.filter(result => !result.ok)
|
||||
|
||||
if (failures.length > 0) {
|
||||
hasFailures = true
|
||||
console.log(`Failures: ${failures.length}`)
|
||||
} else {
|
||||
console.log('Failures: 0')
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFailures) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
applyResponseDefaults
|
||||
} from '~/helpers/astro/request.js'
|
||||
import { deviceSupportsApp } from '~/helpers/devices.js'
|
||||
import { getDeviceListingBySlug } from '~/helpers/site-listings.js'
|
||||
|
||||
|
||||
import Layout from '../../layouts/default.astro'
|
||||
|
|
@ -36,7 +37,22 @@ if ( redirectResponse !== null ) {
|
|||
|
||||
applyResponseDefaults( Astro )
|
||||
|
||||
const device = await DoesItAPI.device( pathSlug ).get()
|
||||
let device
|
||||
|
||||
try {
|
||||
device = await DoesItAPI.device( pathSlug ).get()
|
||||
} catch ( error ) {
|
||||
if ( error?.response?.status !== 404 ) {
|
||||
throw error
|
||||
}
|
||||
|
||||
device = getDeviceListingBySlug( pathSlug )
|
||||
}
|
||||
|
||||
if ( device === null || typeof device === 'undefined' ) {
|
||||
return Astro.redirect( '/devices' )
|
||||
}
|
||||
|
||||
const rawAppPage = await DoesItAPI.kind( 'app' )( subSlug ).get()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
getVideoImages,
|
||||
ListingDetails
|
||||
} from '~/helpers/listing-page.js'
|
||||
import { getVideoListingBySlug } from '~/helpers/site-listings.js'
|
||||
import { getPathPartsFromAstroRequest } from '~/helpers/url.js'
|
||||
|
||||
import Layout from '~/src/layouts/default.astro'
|
||||
|
|
@ -39,7 +40,21 @@ const {
|
|||
// https://docs.astro.build/en/reference/api-reference/#astrorequests
|
||||
|
||||
// Request App data from API
|
||||
const tvListing = await DoesItAPI.tv( pathSlug ).get()
|
||||
let tvListing
|
||||
|
||||
try {
|
||||
tvListing = await DoesItAPI.tv( pathSlug ).get()
|
||||
} catch ( error ) {
|
||||
if ( error?.response?.status !== 404 ) {
|
||||
throw error
|
||||
}
|
||||
|
||||
tvListing = await getVideoListingBySlug( pathSlug )
|
||||
}
|
||||
|
||||
if ( tvListing === null || typeof tvListing === 'undefined' ) {
|
||||
return Astro.redirect( '/benchmarks' )
|
||||
}
|
||||
|
||||
const listingDetails = new ListingDetails( tvListing )
|
||||
|
||||
|
|
|
|||
29
test/prebuild/site-listings.test.js
Normal file
29
test/prebuild/site-listings.test.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getDeviceListingBySlug,
|
||||
getVideoListingBySlug
|
||||
} from '~/helpers/site-listings.js'
|
||||
|
||||
describe( 'site listing fallbacks', () => {
|
||||
it( 'loads known devices from the bundled device list', () => {
|
||||
expect( getDeviceListingBySlug( 'm1-imac' ) ).toMatchObject({
|
||||
name: 'M1 iMac',
|
||||
endpoint: '/device/m1-imac'
|
||||
})
|
||||
})
|
||||
|
||||
it( 'rebuilds known tv listings from the bundled YouTube source', async () => {
|
||||
await expect(
|
||||
getVideoListingBySlug( 'install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i' )
|
||||
).resolves.toMatchObject({
|
||||
endpoint: '/tv/install-instagram-app-on-m1-macbook-air-apple-silicon-tutorial-i-vfbmworal6i'
|
||||
})
|
||||
})
|
||||
|
||||
it( 'returns null for missing tv slugs', async () => {
|
||||
await expect(
|
||||
getVideoListingBySlug( 'apple-silicon-gaming-is-here' )
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue