From 820e495d2d106136475113feb41a9739e6eac987 Mon Sep 17 00:00:00 2001 From: ThatGuySam Date: Mon, 6 Apr 2026 10:31:57 -0500 Subject: [PATCH 1/7] Keep redirect lookups from crashing SSR pages Dynamic Astro routes were reading Netlify redirect config through a cwd-relative path, which is fragile inside a serverless runtime and was taking detail pages down with 500s before render. Resolve netlify.toml by searching from the module directory and current working directory, and fail open in request-time redirect lookup so a config read problem does not block page rendering. Constraint: Netlify serverless cwd is not guaranteed to be the repo root Rejected: Inline redirects into route modules | would duplicate platform config and drift from source of truth Rejected: Leave redirect lookup hard-failing | one config read failure should not take down unrelated pages Confidence: medium Scope-risk: narrow Reversibility: clean Directive: Keep redirect config lookup independent of process cwd anywhere server code reads deploy config files Tested: vitest ./test/prebuild/config-node.test.js; pnpm run netlify-build Not-tested: live Netlify production deploy before push --- helpers/astro/request.js | 9 +++++-- helpers/config-node.js | 38 +++++++++++++++++++++++++++- test/prebuild/config-node.test.js | 41 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 test/prebuild/config-node.test.js diff --git a/helpers/astro/request.js b/helpers/astro/request.js index 5c9efb9..959d446 100644 --- a/helpers/astro/request.js +++ b/helpers/astro/request.js @@ -56,7 +56,13 @@ export async function applyResponseDefaults ( Astro ) { export async function catchRedirectResponse ( Astro ) { const requestUrl = new URL( Astro.request.url ) - const netlifyRedirectUrl = await getNetlifyRedirect( requestUrl.pathname ) + let netlifyRedirectUrl = null + + try { + netlifyRedirectUrl = await getNetlifyRedirect( requestUrl.pathname ) + } catch ( error ) { + console.warn( `Skipping redirect lookup for ${ requestUrl.pathname }`, error ) + } // console.log('netlifyRedirectUrl', netlifyRedirectUrl) @@ -67,4 +73,3 @@ export async function catchRedirectResponse ( Astro ) { return null } - diff --git a/helpers/config-node.js b/helpers/config-node.js index c24acef..75dec1a 100644 --- a/helpers/config-node.js +++ b/helpers/config-node.js @@ -1,5 +1,7 @@ import TOML from '@iarna/toml' import fs from 'fs-extra' +import path from 'path' +import { fileURLToPath } from 'url' import pkg from '~/package.json' import { publicRuntimeConfig } from '~/helpers/public-runtime-config.mjs' @@ -8,6 +10,7 @@ import { getRouteType } from '~/helpers/app-derived.js' export const siteUrl = getSiteUrl() +const currentModuleDirectory = path.dirname( fileURLToPath( import.meta.url ) ) export const nuxtHead = { // this htmlAttrs you need @@ -113,8 +116,41 @@ export const nuxtHead = { +export async function getNetlifyConfigPath () { + const searchDirectories = new Set() + + // Local dev usually runs from repo root, but deployed serverless + // functions may execute from a nested working directory. + for ( const baseDirectory of [ + process.cwd(), + currentModuleDirectory, + ] ) { + let directory = baseDirectory + + while ( true ) { + searchDirectories.add( directory ) + + const parentDirectory = path.dirname( directory ) + + if ( parentDirectory === directory ) break + + directory = parentDirectory + } + } + + for ( const directory of searchDirectories ) { + const configPath = path.join( directory, 'netlify.toml' ) + + if ( await fs.pathExists( configPath ) ) { + return configPath + } + } + + throw new Error( 'Could not find netlify.toml' ) +} + export async function getNetlifyConfig () { - const configPath = './netlify.toml' + const configPath = await getNetlifyConfigPath() const tomlContent = await fs.readFile(configPath, 'utf-8') const netlifyConfig = TOML.parse(tomlContent) diff --git a/test/prebuild/config-node.test.js b/test/prebuild/config-node.test.js new file mode 100644 index 0000000..0a16617 --- /dev/null +++ b/test/prebuild/config-node.test.js @@ -0,0 +1,41 @@ +import fs from 'fs-extra' +import os from 'os' +import path from 'path' +import { afterEach, describe, expect, it } from 'vitest' + +import { + getNetlifyConfigPath, + getNetlifyRedirect +} from '~/helpers/config-node.js' + +const originalCwd = process.cwd() + +afterEach(() => { + process.chdir( originalCwd ) +}) + +describe( 'netlify config helpers', () => { + it( 'resolves netlify.toml even when cwd is outside the repo root', async () => { + const tempDirectory = await fs.mkdtemp( path.join( os.tmpdir(), 'doesitarm-netlify-' ) ) + + process.chdir( tempDirectory ) + + const configPath = await getNetlifyConfigPath() + + expect( configPath ).toBe( path.join( originalCwd, 'netlify.toml' ) ) + }) + + it( 'loads redirects when cwd is outside the repo root', async () => { + const tempDirectory = await fs.mkdtemp( path.join( os.tmpdir(), 'doesitarm-netlify-' ) ) + + process.chdir( tempDirectory ) + + const redirect = await getNetlifyRedirect( '/app/electron' ) + + expect( redirect ).toMatchObject({ + from: '/app/electron', + to: '/app/electron-framework', + status: 301 + }) + }) +}) From 6cfbfbf5306c8bb93b2ec007c759b4681dfb4f43 Mon Sep 17 00:00:00 2001 From: ThatGuySam Date: Mon, 6 Apr 2026 10:51:49 -0500 Subject: [PATCH 2/7] 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 --- helpers/api/client.js | 5 +- helpers/build-video-list.js | 11 ++- helpers/site-listings.js | 47 +++++++++++ scripts/health | 104 +++++++++++++++++++++++++ src/pages/device/[...devicePath].astro | 18 ++++- src/pages/tv/[...videoPath].astro | 17 +++- test/prebuild/site-listings.test.js | 29 +++++++ 7 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 helpers/site-listings.js create mode 100755 scripts/health create mode 100644 test/prebuild/site-listings.test.js diff --git a/helpers/api/client.js b/helpers/api/client.js index 2e3b7bc..d5d06bb 100644 --- a/helpers/api/client.js +++ b/helpers/api/client.js @@ -25,7 +25,10 @@ const defaultFetchMethod = async function (...args) { return axios(...args) .then( response => response.data ) .catch( error => { - console.error( error ) + if ( error?.response?.status !== 404 ) { + console.error( error ) + } + throw error }) } diff --git a/helpers/build-video-list.js b/helpers/build-video-list.js index a3df213..634e057 100644 --- a/helpers/build-video-list.js +++ b/helpers/build-video-list.js @@ -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 diff --git a/helpers/site-listings.js b/helpers/site-listings.js new file mode 100644 index 0000000..6ba4c90 --- /dev/null +++ b/helpers/site-listings.js @@ -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 +} diff --git a/scripts/health b/scripts/health new file mode 100755 index 0000000..3c5c81a --- /dev/null +++ b/scripts/health @@ -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>/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) +} diff --git a/src/pages/device/[...devicePath].astro b/src/pages/device/[...devicePath].astro index 7207eaf..2f79bfe 100644 --- a/src/pages/device/[...devicePath].astro +++ b/src/pages/device/[...devicePath].astro @@ -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() diff --git a/src/pages/tv/[...videoPath].astro b/src/pages/tv/[...videoPath].astro index 6621668..fafeb2f 100644 --- a/src/pages/tv/[...videoPath].astro +++ b/src/pages/tv/[...videoPath].astro @@ -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 ) diff --git a/test/prebuild/site-listings.test.js b/test/prebuild/site-listings.test.js new file mode 100644 index 0000000..7c27420 --- /dev/null +++ b/test/prebuild/site-listings.test.js @@ -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() + }) +}) From d39a2a1d6cf2fdcd1b76bcc13a2a507bb5c85feb Mon Sep 17 00:00:00 2001 From: ThatGuySam <sam@sam.lc> Date: Mon, 6 Apr 2026 11:00:53 -0500 Subject: [PATCH 3/7] Bundle fallback data into SSR instead of reading repo-local files The previous route fallback fix worked locally but still failed on production because the Netlify SSR runtime did not have repo-local JSON files available at the paths the helper searched. Switch the fallback helper to raw-import the generated app, game, device, and YouTube JSON inputs so the SSR bundle carries the data it needs at runtime, independent of function working directory or file packaging quirks. Constraint: Netlify SSR bundling does not reliably expose repo-local generated files as runtime-readable filesystem paths Rejected: Rely on Netlify included_files for SSR bundle data | the generated SSR function archive still omitted the fallback files Rejected: Fetch large fallback JSON over HTTP on each request | unnecessary network dependency for a server-side fallback path Confidence: medium Scope-risk: narrow Reversibility: clean Directive: Prefer bundler-native inclusion for SSR-only fallback data when runtime file availability is uncertain on Netlify Tested: vitest ./test/prebuild/config-node.test.js ./test/prebuild/site-listings.test.js; pnpm run netlify-build Not-tested: live production after redeploy --- helpers/site-listings.js | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/helpers/site-listings.js b/helpers/site-listings.js index 6ba4c90..cacca60 100644 --- a/helpers/site-listings.js +++ b/helpers/site-listings.js @@ -1,43 +1,33 @@ -import fs from 'fs-extra' -import path from 'path' -import { fileURLToPath } from 'url' +import youtubeVideosText from '~/static/api/youtube-videos.json?raw' +import appListText from '~/static/app-list.json?raw' +import deviceListText from '~/static/device-list.json?raw' +import gameListText from '~/static/game-list.json?raw' 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' ) ) -} +const parsedDeviceList = JSON.parse( deviceListText.replace( trailingCommaPattern, '$1' ) ) +const parsedAppList = JSON.parse( appListText.replace( trailingCommaPattern, '$1' ) ) +const parsedGameList = JSON.parse( gameListText.replace( trailingCommaPattern, '$1' ) ) +const parsedYoutubeVideos = JSON.parse( youtubeVideosText ) export function getDeviceListingBySlug ( slug ) { - const deviceList = parseGeneratedJsonFile( deviceListPath ) - - return deviceList.find( device => device.slug === slug ) || null + return parsedDeviceList.find( device => device.slug === slug ) || null } function getAllVideoAppsList () { return [ - ...parseGeneratedJsonFile( appListPath ), - ...parseGeneratedJsonFile( gameListPath ) + ...parsedAppList, + ...parsedGameList ] } export async function getVideoListingBySlug ( slug ) { - const fetchedVideos = await fs.readJson( youtubeVideoPath ) const allVideoAppsList = getAllVideoAppsList() - for ( const [ videoId, fetchedVideo ] of Object.entries( fetchedVideos ) ) { + for ( const [ videoId, fetchedVideo ] of Object.entries( parsedYoutubeVideos ) ) { if ( makeVideoSlug( fetchedVideo.title, videoId ) !== slug ) continue return await buildVideoListingFromFetchedVideo( fetchedVideo, videoId, allVideoAppsList ) From d45b587434372ba1fa24454e604ff9b058e0c341 Mon Sep 17 00:00:00 2001 From: ThatGuySam <sam@sam.lc> Date: Mon, 6 Apr 2026 12:09:16 -0500 Subject: [PATCH 4/7] Finish axios migration via shared native HTTP helper Replace all in-scope axios callsites with a new helpers/http.js wrapper over native fetch, including JSON/text GET, JSON POST, HEAD checks, and transient 5xx retry behavior; update all browser, build, script, and proxy API clients to use it; add focused unit tests; and remove axios from package dependencies. Constraint: Preserve API/build and deployment behavior while lowering transport surface area. Rejected: inline fetch replacements at each callsite | rejected to avoid inconsistent error/retry semantics. Confidence: high Scope-risk: moderate Directive: Keep helper in place as the transport boundary and update tests when changing request semantics. Tested: pnpm run -s typecheck, pnpm -s run test-prebuild, pnpm -s run test, pnpm -s run test:browser, pnpm -s run netlify-build, smoke GETs on /apple-silicon-app-test and /apple-silicon-app-test/?version=2 Not-tested: branch/netlify deployment health in CI pipeline after merge --- components/all-updates-subscribe.vue | 27 +- components/email-subscribe.vue | 35 +-- docs/plans/axios-removal.md | 269 +++++++++++++++++++ helpers/api/client.js | 6 +- helpers/api/sitemap/parse.js | 15 +- helpers/api/static.js | 9 +- helpers/api/youtube/build.js | 5 +- helpers/app-files-scanner.js | 9 +- helpers/build-app-list.js | 26 +- helpers/build-device-list.js | 8 +- helpers/build-game-list.js | 8 +- helpers/build-homebrew-list.js | 12 +- helpers/build-video-list.js | 2 +- helpers/http.js | 244 +++++++++++++++++ helpers/pagefind/load-sitemap-endpoints.ts | 34 +-- package.json | 1 - pages/apple-silicon-app-test.vue | 1 - pnpm-lock.yaml | 24 -- scripts/download-sitemaps.js | 6 +- scripts/scan-new-apps.js | 6 +- scripts/vercel-post-deploy/index.js | 4 +- test/listings/index.test.ts | 5 +- test/prebuild/http.test.ts | 170 ++++++++++++ test/prebuild/load-sitemap-endpoints.test.ts | 104 +++---- test/scanner/plist.test.ts | 61 ++--- 25 files changed, 824 insertions(+), 267 deletions(-) create mode 100644 docs/plans/axios-removal.md create mode 100644 helpers/http.js create mode 100644 test/prebuild/http.test.ts diff --git a/components/all-updates-subscribe.vue b/components/all-updates-subscribe.vue index d24abf1..b6741a8 100644 --- a/components/all-updates-subscribe.vue +++ b/components/all-updates-subscribe.vue @@ -59,10 +59,10 @@ <script> -import axios from 'axios' import { v4 as uuid } from 'uuid' import { isNuxt } from '~/helpers/environment.js' +import { postJson } from '~/helpers/http.js' export default { props: { @@ -142,31 +142,16 @@ export default { console.log('actionUrl', actionUrl) - axios({ - method: 'post', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - url: actionUrl, - data: { + try { + await postJson( actionUrl, { // Email 'email': this.email - }, - }).then( response => { - + } ) this.feedbackMessage = 'We\'ll keep you informed!' - - // console.log('response', response) - // if (response.status === 200) { - // this.feedbackMessage = '- We\'ll keep an eye on it for you!' - // } else { - // this.feedbackMessage = 'Oops! Something went wrong' - // } - }).catch( error => { + } catch ( error ) { console.warn('error', error) this.feedbackMessage = 'Something went wrong. Try refreshing. ' - }) + } // .catch(error => { // // handle error diff --git a/components/email-subscribe.vue b/components/email-subscribe.vue index 843f997..70bbb4c 100644 --- a/components/email-subscribe.vue +++ b/components/email-subscribe.vue @@ -59,8 +59,6 @@ <script> -import axios from 'axios' - export default { props: { appName: { @@ -121,31 +119,16 @@ export default { const formActionUrl = `https://docs.google.com/forms/d/e/1FAIpQLSdWUAVabT3i1ExfnPgRKnk-s-aWLlOuy0d5JjMKDwKtrwXj1Q/formResponse?entry.710297191=${this.appName}&emailAddress=${this.email}&submit=Submit` - axios({ - method: 'get', - url: formActionUrl, - // data: { - // // Email - // 'emailAddress': this.email, - - // // App Name - // 'entry.710297191': this.appName, - // // Notes - // // 'entry.2040856090': '', - - // 'submit': 'Submit' - // }, - }).finally( response => { - + try { + await fetch( formActionUrl, { + method: 'GET', + mode: 'no-cors' + } ) + } catch ( error ) { + console.warn( 'Error Subscribing -', error ) + } finally { this.feedbackMessage = 'We\'ll keep an eye on it for you!' - - // console.log('response', response) - // if (response.status === 200) { - // this.feedbackMessage = '- We\'ll keep an eye on it for you!' - // } else { - // this.feedbackMessage = 'Oops! Something went wrong' - // } - }) + } // .catch(error => { // // handle error diff --git a/docs/plans/axios-removal.md b/docs/plans/axios-removal.md new file mode 100644 index 0000000..d6adf72 --- /dev/null +++ b/docs/plans/axios-removal.md @@ -0,0 +1,269 @@ +# Original Prompt + +> How much refactoring would it be to remove axios? +> +> Make a plan file for this. + +# Goal + +Remove `axios` from the repo without breaking CI, production deploys, app +scanning, or the current API/build flows, while replacing it with one shared, +testable HTTP layer instead of many ad hoc call-site rewrites. + +## 2026-04-06 Progress Update + +- Completed end-to-end migration of the in-scope call sites to + `helpers/http.js`. +- Added and tested shared HTTP helper: + - `helpers/http.js` (new) + - `test/prebuild/http.test.ts` (new) +- `package.json` no longer lists `axios`. +- Runtime code `axios` references are currently clean (`rg -n "axios"` excluding + `docs/**` and `pnpm-lock.yaml` returns no hits). +- Validation completed in this pass: + - `pnpm run -s typecheck` ✅ + - `pnpm -s run test-prebuild` ✅ + - `pnpm -s run test` ✅ + - `pnpm -s run test:browser` ✅ + - `pnpm -s run lint` ❌ (existing repo-wide lint issues unrelated to axios migration) +- Remaining to close out final gate from this plan: + - execute `pnpm run netlify-build` ✅ + - run live deploy verification (`apple-silicon-app-test` URLs) ✅ + - confirm whether `pnpm-lock.yaml` changes are sufficient for your lockfile policy + +# Non-Goals + +- Replace every network call with raw `fetch` inline at each call site. +- Change product behavior just to simplify the transport layer. +- Migrate unrelated TypeScript or architecture work in the same change set. +- Introduce a new HTTP dependency when the repo can already support the + replacement with current runtime capabilities. + +# Repo Findings + +- `axios` is referenced in `19` files across the repo. +- The majority of those call sites are shallow: +- simple JSON/text `GET` requests in build helpers and scripts +- one `HEAD` existence check for sitemap discovery +- a few browser-side `POST`/`GET` form submissions +- one scanner-side JSON `POST` +- The riskiest `axios` surface is + [helpers/api/client.js](/Users/athena/Code/doesitarm/helpers/api/client.js), + because it is the generic proxy-based API wrapper used by tests and other + helper code. +- The most deployment-sensitive `axios` paths are: +- [helpers/pagefind/load-sitemap-endpoints.ts](/Users/athena/Code/doesitarm/helpers/pagefind/load-sitemap-endpoints.ts) +- [scripts/build-pagefind-index.js](/Users/athena/Code/doesitarm/scripts/build-pagefind-index.js) +- [helpers/api/sitemap/parse.js](/Users/athena/Code/doesitarm/helpers/api/sitemap/parse.js) +- These already proved that small network behavior changes can affect the full + Netlify build and production deploy. +- The app-scanning/browser-critical `axios` path is + [helpers/app-files-scanner.js](/Users/athena/Code/doesitarm/helpers/app-files-scanner.js), + where scan submission posts to `TEST_RESULT_STORE`. +- Browser-side subscription forms still use direct `axios` calls in: +- [components/all-updates-subscribe.vue](/Users/athena/Code/doesitarm/components/all-updates-subscribe.vue) +- [components/email-subscribe.vue](/Users/athena/Code/doesitarm/components/email-subscribe.vue) +- Test and validation surfaces already exist that can protect this refactor: +- parser/module tests under `test/scanner/` +- prebuild tests under `test/prebuild/` +- broader repo tests via `pnpm run test` +- browser regression coverage via `pnpm run test:browser` +- live production checks already include remote Pagefind and app-scanning smoke + verification, and deploy verification now includes explicit Netlify deploy + inspection. +- The repo already depends on `ofetch`, but it is not used anywhere today. +- Node 24 also provides native `fetch`, so removing `axios` does not require a + new dependency. + +# Recommendation + +Remove `axios` in stages behind one small shared HTTP helper instead of +replacing each call site with custom `fetch` logic. + +Preferred implementation direction: + +- add a repo-local HTTP helper built on native `fetch` +- keep helper methods narrow and explicit: +- `getJson` +- `getText` +- `postJson` +- `headOk` +- optional retry wrapper only where external build inputs justify it +- migrate the most deployment-sensitive build callers first, then browser/API + callers, and only migrate the generic proxy client last + +Rationale: + +- native `fetch` reduces dependency surface and works in Node 24 plus browsers +- one helper preserves consistent error handling and retry policy +- the helper can be unit tested directly, which fits the repo’s new + small-test-first verification preference + +# Rollout Plan + +1. Add the shared HTTP helper and direct unit coverage. ✅ +- Create a small helper module under `helpers/` that wraps native `fetch`. ✅ +- Support the exact behaviors needed by current callers: +- JSON GET +- text GET +- JSON POST +- HEAD success/failure check +- optional retry for transient `5xx` failures +- Add direct unit tests for: +- retry on `5xx` +- no retry on `4xx` +- JSON/body parsing behavior +- `headOk` result mapping +- Keep this slice independent of caller migration so it can be reviewed and + tested in isolation. + +2. Migrate deploy- and build-sensitive callers first. ✅ +- Replace `axios` in the callers that affect Netlify/CI success: +- `helpers/pagefind/load-sitemap-endpoints.ts` +- `scripts/build-pagefind-index.js` +- `scripts/download-sitemaps.js` +- `helpers/api/sitemap/parse.js` +- `helpers/api/static.js` +- Add or extend focused prebuild tests for any changed retry/error semantics. +- Verify with `pnpm run test-prebuild` before broader test runs. ✅ + +3. Migrate data-fetching build helpers. ✅ +- Replace `axios` in: +- `helpers/build-app-list.js` +- `helpers/build-homebrew-list.js` +- `helpers/build-game-list.js` +- `helpers/build-device-list.js` +- `helpers/api/youtube/build.js` +- Keep output behavior identical; this stage is about transport replacement, not + data model cleanup. +- Validate with: +- `pnpm run test` ✅ +- `pnpm netlify-build` ✅ + +4. Migrate browser-side form and scanner callers. ✅ +- Replace `axios` in: +- `helpers/app-files-scanner.js` +- `components/all-updates-subscribe.vue` +- `components/email-subscribe.vue` +- Preserve current UX semantics: +- success/failure messages +- request payload shapes +- request methods +- Re-run: +- `pnpm run test:browser` ✅ +- production app-scanning smoke against both app-test routes ✅ + +5. Migrate the generic API proxy wrapper last. ✅ +- Replace `axios` in + [helpers/api/client.js](/Users/athena/Code/doesitarm/helpers/api/client.js) + only after the lower-risk callers are green. ✅ +- Add a focused unit around the generated API client so the wrapper’s transport + semantics stay stable. ✅ +- Update any tests that directly mock `axios` so they mock the new helper + instead. ✅ + +6. Remove `axios` from the repo. +- Remove `axios` from + [package.json](/Users/athena/Code/doesitarm/package.json). ✅ +- Do a final grep to confirm no runtime code imports remain. ✅ +- Re-run the full repo validation and live deploy verification. ✅ + +# Execution Order For This Pass + +1. Add `helpers/http.js` with direct unit tests that lock: +- JSON GET parsing +- text GET parsing +- JSON POST payload + response handling +- `HEAD` success/failure mapping +- retry on `5xx` but not on `4xx` + +2. Migrate the already-tested sitemap/pagefind caller path first. +- `helpers/pagefind/load-sitemap-endpoints.ts` +- `test/prebuild/load-sitemap-endpoints.test.ts` + +3. Migrate the remaining build and script callers that only need text or JSON GET. +- `scripts/download-sitemaps.js` +- `helpers/api/static.js` +- `helpers/api/sitemap/parse.js` +- `helpers/build-*.js` +- `helpers/api/youtube/build.js` +- `scripts/scan-new-apps.js` +- `scripts/vercel-post-deploy/index.js` + +4. Migrate the browser and API-wrapper callers with focused regression coverage. +- `helpers/app-files-scanner.js` +- `components/all-updates-subscribe.vue` +- `components/email-subscribe.vue` +- `helpers/api/client.js` +- `test/listings/index.test.ts` + +5. Remove `axios` from dependency and lockfile after grep is clean. + +# Validation Gates + +- Shared HTTP helper stage: +- direct unit tests for helper behavior +- `pnpm run typecheck` + +- Build/prebuild migration stages: +- `pnpm run test-prebuild` +- `pnpm run typecheck` +- `pnpm netlify-build` + +- Browser/scanner migration stages: +- `pnpm run test` +- `pnpm run test:browser` +- production smoke against: +- `https://doesitarm.com/apple-silicon-app-test/` +- `https://doesitarm.com/apple-silicon-app-test/?version=2` + +- Final removal gate: +- `pnpm run typecheck` +- `pnpm run test` +- `pnpm run test-prebuild` +- `pnpm run test:browser` +- inspect the latest Netlify deploy via CLI/API and confirm the production + deploy reaches `ready` + +# Deliverables + +- A repo-local axios removal plan in `docs/plans/axios-removal.md` +- A shared HTTP helper with direct tests +- Smaller migration commits by caller category +- Updated tests that no longer depend on mocking `axios` +- Removal of `axios` from runtime dependencies + +# Risks And Open Questions + +- The generic proxy client in `helpers/api/client.js` may hide assumptions about + request config and returned error shape. +- Browser form submissions may rely on current implicit `axios` defaults that + need to be replicated explicitly with `fetch`. +- Build/deploy callers are sensitive to transient upstream failures and should + keep explicit retry behavior where justified. +- A literal “replace axios with fetch everywhere” pass would be easy to do + badly; the helper-first approach is safer and more testable. +- If some callers need richer timeout or redirect behavior than native `fetch` + exposes cleanly, that should be solved in the helper, not reintroduced + piecemeal at call sites. + +# Sources + +- `package.json` +- `helpers/api/client.js` +- `helpers/api/static.js` +- `helpers/api/sitemap/parse.js` +- `helpers/pagefind/load-sitemap-endpoints.ts` +- `helpers/app-files-scanner.js` +- `helpers/build-app-list.js` +- `helpers/build-homebrew-list.js` +- `helpers/build-game-list.js` +- `helpers/build-device-list.js` +- `helpers/api/youtube/build.js` +- `components/all-updates-subscribe.vue` +- `components/email-subscribe.vue` +- `scripts/build-pagefind-index.js` +- `scripts/download-sitemaps.js` +- `scripts/scan-new-apps.js` +- `test/listings/index.test.ts` +- `test/prebuild/load-sitemap-endpoints.test.ts` diff --git a/helpers/api/client.js b/helpers/api/client.js index d5d06bb..6588e62 100644 --- a/helpers/api/client.js +++ b/helpers/api/client.js @@ -12,9 +12,8 @@ // GET /api/tiles/public/static/3/4/2.json?turn=37038&games=wot // DoesItAPI.tiles.public.static(3)(4)(`${2}.json`).get({ turn: 37, games: 'wot' }) -import axios from 'axios' - import { getApiUrl } from '~/helpers/url.js' +import { requestJson } from '~/helpers/http.js' // Use msw import '~/test/msw/use.js' @@ -22,8 +21,7 @@ import '~/test/msw/use.js' // const defaultFetchMethod = (...args) => console.log(...args) // mock const defaultFetchMethod = async function (...args) { - return axios(...args) - .then( response => response.data ) + return requestJson(...args) .catch( error => { if ( error?.response?.status !== 404 ) { console.error( error ) diff --git a/helpers/api/sitemap/parse.js b/helpers/api/sitemap/parse.js index 7c88449..8b0929c 100644 --- a/helpers/api/sitemap/parse.js +++ b/helpers/api/sitemap/parse.js @@ -1,6 +1,5 @@ import path from 'path' import fs from 'fs-extra' -import axios from 'axios' import { parse } from 'fast-xml-parser' import { @@ -8,6 +7,10 @@ import { sitemapIndexFileName, } from '~/helpers/constants.js' import { isValidHttpUrl } from '~/helpers/check-types.js' +import { + getText, + headOk +} from '~/helpers/http.js' const sitemapFilesToTry = [ sitemapIndexFileName, @@ -106,12 +109,7 @@ export async function fetchAllUrlsFromSitemaps ( urlString ) { // console.log( 'sitemapUrl', sitemapUrl.href ) // Just do a quich HEAD request to see if the file exists with getting the whole body - const exists = await axios.head( sitemapUrl.href ) - .catch( () => false ) - .then( response => { - // console.log( 'response', response.status ) - return response.status < 300 - } ) + const exists = await headOk( sitemapUrl.href ) // console.log( 'exists', exists ) @@ -123,8 +121,7 @@ export async function fetchAllUrlsFromSitemaps ( urlString ) { getMethod: async sitemapPath => { const sitemapUrl = new URL( sitemapPath, urlString ) - const sitemapXml = await axios.get( sitemapUrl.href ) - .then( response => response.data ) + const sitemapXml = await getText( sitemapUrl.href ) return sitemapXml } diff --git a/helpers/api/static.js b/helpers/api/static.js index d27caf9..3a42c69 100644 --- a/helpers/api/static.js +++ b/helpers/api/static.js @@ -1,5 +1,4 @@ import fs from 'fs-extra' -import axios from 'axios' import 'dotenv/config.js' import { @@ -8,6 +7,7 @@ import { // storkExecutablePath, storkTomlPath, } from '~/helpers/stork/config.js' +import { getText } from '~/helpers/http.js' export async function downloadStorkToml () { // Check if the toml file exists @@ -20,12 +20,9 @@ export async function downloadStorkToml () { apiUrl.pathname = storkTomlPath.replace('static/', '') - const response = await axios({ - method: "get", - url: apiUrl.toString(), - }) + const storkToml = await getText( apiUrl.toString() ) - await fs.writeFile( storkTomlPath, response.data, { encoding: null }) + await fs.writeFile( storkTomlPath, storkToml, { encoding: null }) // Get toml file stats const stats = await fs.stat( storkTomlPath ) diff --git a/helpers/api/youtube/build.js b/helpers/api/youtube/build.js index 8f5ebf9..8fc4767 100644 --- a/helpers/api/youtube/build.js +++ b/helpers/api/youtube/build.js @@ -1,8 +1,8 @@ import fs from 'fs-extra' import { google } from 'googleapis' -import axios from 'axios' import { playlists, benchmarksPlaylistId } from './playlists.js' +import { getJson } from '~/helpers/http.js' export const youtubeVideoPath = './static/api/youtube-videos.json' @@ -167,8 +167,7 @@ export async function saveYouTubeVideos () { // const youtubeVideos = await getYouTubeVideos() // Locked previously sucessful YouTube API data for now - const youtubeVideos = await axios( process.env.VIDEO_SOURCE ) - .then( response => response.data ) + const youtubeVideos = await getJson( process.env.VIDEO_SOURCE ) // Save to JSON diff --git a/helpers/app-files-scanner.js b/helpers/app-files-scanner.js index 3f4e555..fb37c50 100644 --- a/helpers/app-files-scanner.js +++ b/helpers/app-files-scanner.js @@ -1,9 +1,9 @@ import plist from 'plist' -import axios from 'axios' import prettyBytes from 'pretty-bytes' import * as zip from '@zip.js/zip.js' import { isString } from './check-types.js' +import { postJson } from './http.js' import parseMacho from './macho/index.js' // Vite Web Workers - https://vitejs.dev/guide/features.html#web-workers @@ -331,14 +331,13 @@ export default class AppFilesScanner { // console.log( 'this.testResultStore', this.testResultStore ) - const { supportedVersionNumber } = await axios.post( this.testResultStore , { + const responseData = await postJson( this.testResultStore, { filename, appVersion, result, machoMeta: JSON.stringify( machoMeta ), infoPlist: JSON.stringify( infoPlist ) - }) - .then( response => response.data ) + } ) .catch(function (error) { console.error(error) @@ -348,7 +347,7 @@ export default class AppFilesScanner { }) return { - supportedVersionNumber + supportedVersionNumber: responseData?.supportedVersionNumber ?? null } } diff --git a/helpers/build-app-list.js b/helpers/build-app-list.js index f2ac1d9..bc13a78 100644 --- a/helpers/build-app-list.js +++ b/helpers/build-app-list.js @@ -1,6 +1,5 @@ import fs from 'fs-extra' import MarkdownIt from 'markdown-it' -import axios from 'axios' import statuses, { getStatusName } from './statuses.js' import appStoreGenres from './app-store/genres.js' @@ -14,6 +13,7 @@ import { byTimeThenNull } from './sort-list.js' import { cliOptions } from '~/helpers/cli-options.js' +import { getJson } from './http.js' const md = new MarkdownIt() @@ -277,9 +277,9 @@ const lookForLastUpdated = function (app, commits) { async function fetchBundleGenres () { const genresJsonUrl = `${process.env.VFUNCTIONS_URL}/app-store/listings-sheet?fields=bundleId,genreIds` - return await axios.get( genresJsonUrl ) - .then( response => { - return new Map( response.data.apps ) + return await getJson( genresJsonUrl ) + .then( data => { + return new Map( data.apps ) }) .catch(function (error) { // handle error @@ -315,9 +315,9 @@ export default async function () { // console.log('readmeContent', readmeContent) // Fetch Commits - const response = await axios.get(process.env.COMMITS_SOURCE) + const response = await getJson( process.env.COMMITS_SOURCE ) // Extract commit from response data - const commits = response.data.data.viewer.repository.defaultBranchRef.target.history.edges + const commits = response.data.viewer.repository.defaultBranchRef.target.history.edges // console.log('commits', commits) // Save commits to file just in case @@ -328,13 +328,11 @@ export default async function () { const scanListMap = new Map() // Store app scans - await axios - .get(process.env.SCANS_SOURCE) - + await getJson( process.env.SCANS_SOURCE ) .then( async response => { const appBundles = [] - for (const appScan of response.data.appList) { + for (const appScan of response.appList) { // Add app to bundle list appBundles.push([ @@ -349,11 +347,7 @@ export default async function () { await fs.writeJson('./static/app-bundles.json', appBundles) - return response - }) - .then(function (response) { - - response.data.appList.forEach( appScan => { + response.appList.forEach( appScan => { const appName = appScan.aliases[0] @@ -424,8 +418,6 @@ export default async function () { relatedLinks }) }) - - return }) .catch(function (error) { // handle error diff --git a/helpers/build-device-list.js b/helpers/build-device-list.js index b7a7993..73b1246 100644 --- a/helpers/build-device-list.js +++ b/helpers/build-device-list.js @@ -1,6 +1,5 @@ -import axios from 'axios' - import { makeSlug } from './slug.js' +import { getJson } from './http.js' export function getDeviceEndpoint ( slug ) { return `/device/${ slug }` @@ -12,10 +11,7 @@ export default async function () { const devicesJsonUrl = `${process.env.VFUNCTIONS_URL}/api/devices` - const rawDeviceList = await axios.get(devicesJsonUrl) - .then( response => { - return response.data - }) + const rawDeviceList = await getJson( devicesJsonUrl ) .catch(function (error) { // handle error console.warn('Error fetching device list', error) diff --git a/helpers/build-game-list.js b/helpers/build-game-list.js index 10cf574..46ef597 100644 --- a/helpers/build-game-list.js +++ b/helpers/build-game-list.js @@ -1,8 +1,7 @@ -import axios from 'axios' - // import { statuses } from './build-app-list' import { getAppEndpoint } from './app-derived' import { makeSlug } from './slug.js' +import { getJson } from './http.js' // console.log('process.env.GAMES_SOURCE', process.env.GAMES_SOURCE) @@ -68,11 +67,10 @@ function parseStatus(game) { export default async function () { // Fetch Sheet data - const gamesSheet = await axios - .get(process.env.GAMES_SOURCE) + const gamesSheet = await getJson( process.env.GAMES_SOURCE ) .then(function (response) { // handle success - return response.data.records + return response.records }) .catch(function (error) { // handle error diff --git a/helpers/build-homebrew-list.js b/helpers/build-homebrew-list.js index 774382a..ca2e902 100644 --- a/helpers/build-homebrew-list.js +++ b/helpers/build-homebrew-list.js @@ -2,7 +2,6 @@ // import { promises as fs } from 'fs' // import MarkdownIt from 'markdown-it' // import slugify from 'slugify' -import axios from 'axios' // import statuses from './statuses' // import parseDate from './parse-github-date' @@ -11,6 +10,7 @@ const marked = require('marked') const HTMLParser = require(`node-html-parser`) import { getAppEndpoint } from './app-derived' +import { getJson } from './http.js' const statusesTranslations = { @@ -117,13 +117,13 @@ class MakeHomebrewList { allFormulaeResponse ] = await Promise.all([ // Fetch Gihub Issue List - axios.get(process.env.HOMEBREW_SOURCE), + getJson( process.env.HOMEBREW_SOURCE ), // Fetch Official Homebrew Formulae List - axios.get('https://formulae.brew.sh/api/formula.json') + getJson( 'https://formulae.brew.sh/api/formula.json' ) ]) // Extract commit from response data - const issueMarkdown = issueResponse.data.data.repository.issue.body + const issueMarkdown = issueResponse.data.repository.issue.body // Parse markdown const issueHTML = marked(issueMarkdown) @@ -132,10 +132,10 @@ class MakeHomebrewList { const dom = HTMLParser.parse(issueHTML) // Store the original array - this.allFormulaeArray = allFormulaeResponse.data + this.allFormulaeArray = allFormulaeResponse // Extract list from allFormulaeResponse and map into an object for easy access - this.allFormulae = Object.fromEntries(allFormulaeResponse.data.map(formula => { + this.allFormulae = Object.fromEntries(allFormulaeResponse.map(formula => { return [ formula.full_name, formula diff --git a/helpers/build-video-list.js b/helpers/build-video-list.js index 634e057..bfef6ab 100644 --- a/helpers/build-video-list.js +++ b/helpers/build-video-list.js @@ -194,7 +194,7 @@ export default async function ( applist ) { // const videosJsonUrl = process.env.VIDEO_SOURCE || `${process.env.VFUNCTIONS_URL}/videos.json` // Fetch Commits - // const response = await axios.get( videosJsonUrl ) + // const response = await fetch( videosJsonUrl ) // Extract commit from response data const fetchedVideos = await fs.readJson( youtubeVideoPath )//response.data diff --git a/helpers/http.js b/helpers/http.js new file mode 100644 index 0000000..fdcc536 --- /dev/null +++ b/helpers/http.js @@ -0,0 +1,244 @@ +function sleep ( delayMs ) { + return new Promise( resolve => setTimeout( resolve, delayMs ) ) +} + +function normalizeUrl ( url ) { + if ( url instanceof URL ) { + return url.toString() + } + + return String( url ) +} + +function toRequestConfig ( input, options = {} ) { + if ( typeof input === 'string' || input instanceof URL ) { + return { + ...options, + url: normalizeUrl( input ) + } + } + + if ( input && typeof input === 'object' && 'url' in input ) { + return { + ...input, + ...options, + url: normalizeUrl( input.url ) + } + } + + throw new Error( 'Expected a request URL or config object with a url field.' ) +} + +function createHeaders ( inputHeaders = {} ) { + return new Headers( inputHeaders ) +} + +function hasResponseStatus ( error ) { + return typeof error?.response?.status === 'number' +} + +export function shouldRetryError ( error ) { + return hasResponseStatus( error ) && error.response.status >= 500 +} + +export class HttpError extends Error { + constructor ( message, { + cause, + data = null, + method, + status, + statusText, + url + } ) { + super( message ) + + this.name = 'HttpError' + this.cause = cause + this.method = method + this.status = status + this.url = url + this.response = { + data, + status, + statusText, + url + } + } +} + +async function parseResponseBody ( response, responseType ) { + if ( responseType === 'none' ) { + return null + } + + if ( responseType === 'text' ) { + return await response.text() + } + + const text = await response.text() + + if ( text.length === 0 ) { + return null + } + + return JSON.parse( text ) +} + +function buildRequestInit ( { + body, + cache, + credentials, + headers, + method, + mode, + redirect, + signal +} ) { + return { + body, + cache, + credentials, + headers, + method, + mode, + redirect, + signal + } +} + +export async function request ( input, options = {} ) { + const config = toRequestConfig( input, options ) + const { + attempts = 1, + cache, + credentials, + data, + delayMs = 1000, + headers: inputHeaders, + method: inputMethod = 'GET', + mode, + redirect, + responseType = 'json', + signal, + url + } = config + const method = inputMethod.toUpperCase() + const headers = createHeaders( inputHeaders ) + + let body + if ( data !== undefined ) { + body = JSON.stringify( data ) + + if ( !headers.has( 'Accept' ) ) { + headers.set( 'Accept', 'application/json' ) + } + + if ( !headers.has( 'Content-Type' ) ) { + headers.set( 'Content-Type', 'application/json' ) + } + } + + let lastError + + for ( let attempt = 1; attempt <= attempts; attempt += 1 ) { + try { + const response = await fetch( url, buildRequestInit({ + body, + cache, + credentials, + headers, + method, + mode, + redirect, + signal + }) ) + const responseData = await parseResponseBody( response, responseType ) + + if ( !response.ok ) { + throw new HttpError( + `${ method } ${ url } failed with status ${ response.status }`, + { + data: responseData, + method, + status: response.status, + statusText: response.statusText, + url + } + ) + } + + return { + data: responseData, + response + } + } catch ( error ) { + lastError = error + + if ( attempt >= attempts || !shouldRetryError( error ) ) { + throw error + } + + await sleep( delayMs ) + } + } + + throw lastError +} + +export async function getJson ( url, options = {} ) { + const { data } = await request( url, { + ...options, + method: 'GET', + responseType: 'json' + } ) + + return data +} + +export async function getText ( url, options = {} ) { + const { data } = await request( url, { + ...options, + method: 'GET', + responseType: 'text' + } ) + + return data +} + +export async function postJson ( url, data, options = {} ) { + const { data: responseData } = await request( url, { + ...options, + data, + method: 'POST', + responseType: 'json' + } ) + + return responseData +} + +export async function requestJson ( input, options = {} ) { + const { data } = await request( input, { + ...options, + responseType: 'json' + } ) + + return data +} + +export async function headOk ( url, options = {} ) { + try { + await request( url, { + ...options, + method: 'HEAD', + responseType: 'none' + } ) + + return true + } catch ( error ) { + if ( error instanceof Error ) { + return false + } + + throw error + } +} diff --git a/helpers/pagefind/load-sitemap-endpoints.ts b/helpers/pagefind/load-sitemap-endpoints.ts index dd27fe4..6491ca4 100644 --- a/helpers/pagefind/load-sitemap-endpoints.ts +++ b/helpers/pagefind/load-sitemap-endpoints.ts @@ -1,16 +1,13 @@ import fs from 'fs-extra' -import axios from 'axios' +import { + getJson, + shouldRetryError +} from '~/helpers/http.js' import { sitemapEndpointsPath } from '~/helpers/pagefind/config.js' -function shouldRetryError ( error: unknown ) { - const status = ( error as { response?: { status?: number } } )?.response?.status - - return typeof status === 'number' && status >= 500 -} - async function fetchJsonWithRetries ( url: string, { @@ -21,25 +18,10 @@ async function fetchJsonWithRetries ( delayMs?: number } = {} ) { - let lastError: unknown - - for ( let attempt = 1; attempt <= attempts; attempt += 1 ) { - try { - const response = await axios.get( url ) - - return response.data - } catch ( error ) { - lastError = error - - if ( attempt >= attempts || !shouldRetryError( error ) ) { - throw error - } - - await new Promise( resolve => setTimeout( resolve, delayMs ) ) - } - } - - throw lastError + return await getJson( url, { + attempts, + delayMs + } ) } export async function loadSitemapEndpoints () { diff --git a/package.json b/package.json index 50e3da1..29a23b0 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "@supercharge/promise-pool": "^2.1.0", "@zip.js/zip.js": "^2.5.25", "astro": "^6.0.4", - "axios": "^0.21.0", "buffer": "^6.0.3", "can-autoplay": "^3.0.0", "chance": "^1.1.7", diff --git a/pages/apple-silicon-app-test.vue b/pages/apple-silicon-app-test.vue index 4f34254..5279b1f 100644 --- a/pages/apple-silicon-app-test.vue +++ b/pages/apple-silicon-app-test.vue @@ -169,7 +169,6 @@ </template> <script> -// import axios from 'axios' import AppFilesScanner from '~/helpers/app-files-scanner.js' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85244ce..e5422fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,9 +47,6 @@ importers: astro: specifier: ^6.0.4 version: 6.0.4(@netlify/blobs@10.7.2)(@types/node@24.12.0)(jiti@2.6.1)(rollup@4.59.0)(terser@5.46.0)(typescript@5.7.3)(yaml@2.8.2) - axios: - specifier: ^0.21.0 - version: 0.21.0 buffer: specifier: ^6.0.3 version: 6.0.3 @@ -2152,10 +2149,6 @@ packages: aws4@1.12.0: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} - axios@0.21.0: - resolution: {integrity: sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==} - deprecated: Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410 - axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -3799,15 +3792,6 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} - follow-redirects@1.15.2: - resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - fontace@0.4.1: resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} @@ -10254,12 +10238,6 @@ snapshots: aws4@1.12.0: {} - axios@0.21.0: - dependencies: - follow-redirects: 1.15.2 - transitivePeerDependencies: - - debug - axobject-query@4.1.0: {} b4a@1.8.0: {} @@ -12147,8 +12125,6 @@ snapshots: fn.name@1.1.0: {} - follow-redirects@1.15.2: {} - fontace@0.4.1: dependencies: fontkitten: 1.0.3 diff --git a/scripts/download-sitemaps.js b/scripts/download-sitemaps.js index d7bcf64..4c60085 100644 --- a/scripts/download-sitemaps.js +++ b/scripts/download-sitemaps.js @@ -1,6 +1,5 @@ import fs from 'fs-extra' import 'dotenv/config.js' -import axios from 'axios' import { sitemapLocation, @@ -8,6 +7,7 @@ import { } from '~/helpers/constants.js' import { parseSitemapXml } from '~/helpers/api/sitemap/parse.js' +import { getText } from '~/helpers/http.js' ;(async () => { @@ -16,7 +16,7 @@ import { parseSitemapXml } from '~/helpers/api/sitemap/parse.js' const sitemapIndexUrl = new URL( `${ sitemapLocation.split('static')[1] }${ sitemapIndexFileName }`, process.env.PUBLIC_API_DOMAIN ) // Fetch Sitemap Index - const sitemapIndexXML = await axios.get( sitemapIndexUrl.href ).then( response => response.data ) + const sitemapIndexXML = await getText( sitemapIndexUrl.href ) // Save Sitemap Index const sitemapIndexFilePath = `${ sitemapLocation }${ sitemapIndexFileName }` @@ -35,7 +35,7 @@ import { parseSitemapXml } from '~/helpers/api/sitemap/parse.js' // sitemapUrl.origin = process.env.PUBLIC_API_DOMAIN // Fetch Sitemap Index - const sitemapXML = await axios.get( apiSitemapUrl.href ).then( response => response.data ) + const sitemapXML = await getText( apiSitemapUrl.href ) // const sitemap = parse( sitemapXML ) diff --git a/scripts/scan-new-apps.js b/scripts/scan-new-apps.js index 2178fb4..f7f0408 100644 --- a/scripts/scan-new-apps.js +++ b/scripts/scan-new-apps.js @@ -1,8 +1,8 @@ import { createServer } from 'vite' -import axios from 'axios' import viteConfig from '~/vite.config.mjs' import { isLinux } from '~/helpers/environment.js' +import { getText } from '~/helpers/http.js' const port = 1337 @@ -29,12 +29,12 @@ const runScans = false console.log(`Server listening on https://${ vercelUrl }:${ port }/`) - const { data } = await axios.get(`http://${ vercelUrl }:${ port }/`) + const data = await getText( `http://${ vercelUrl }:${ port }/` ) .catch( err => { console.log( 'err', err ) }) - console.log( data.slice(0, 100) ) + console.log( data?.slice(0, 100) ) await server.close(); diff --git a/scripts/vercel-post-deploy/index.js b/scripts/vercel-post-deploy/index.js index 3757ed7..7847009 100644 --- a/scripts/vercel-post-deploy/index.js +++ b/scripts/vercel-post-deploy/index.js @@ -1,6 +1,6 @@ import { isMacOS } from 'std-env' -import axios from 'axios' +import { getText } from '~/helpers/http.js' ;(async () => { @@ -10,7 +10,7 @@ import axios from 'axios' process.exit() } - const { data } = await axios.get(`https://master--doesitarm.netlify.app/apple-silicon-app-test`) + const data = await getText( 'https://master--doesitarm.netlify.app/apple-silicon-app-test' ) console.log( data.slice(0, 100) ) diff --git a/test/listings/index.test.ts b/test/listings/index.test.ts index e474c68..d000660 100644 --- a/test/listings/index.test.ts +++ b/test/listings/index.test.ts @@ -1,7 +1,6 @@ import fs from 'fs-extra' import has from 'just-has' import { test, expect, beforeAll } from 'vitest' -import axios from 'axios' import { JSDOM } from 'jsdom' import { structuredDataTestHtml } from 'structured-data-testing-tool' import { Google } from 'structured-data-testing-tool/presets' @@ -11,6 +10,7 @@ import { getVideoImages, ListingDetails } from '~/helpers/listing-page.js' +import { getJson } from '~/helpers/http.js' import { headPropertyTypes } from '~/test/helpers/head.js' import { PageHead } from '~/helpers/config-node.js' @@ -73,8 +73,7 @@ beforeAll(async () => { continue } - const { data } = await axios.get(`${process.env.PUBLIC_API_DOMAIN}${apiPath}`) - context.listings[caseEndpoint] = data + context.listings[caseEndpoint] = await getJson( `${process.env.PUBLIC_API_DOMAIN}${apiPath}` ) } // Initialize listing details diff --git a/test/prebuild/http.test.ts b/test/prebuild/http.test.ts new file mode 100644 index 0000000..3c6be1f --- /dev/null +++ b/test/prebuild/http.test.ts @@ -0,0 +1,170 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest' + +import { + getJson, + getText, + headOk, + postJson, + requestJson, + shouldRetryError +} from '~/helpers/http.js' + +describe( 'http helper', () => { + const fetchMock = vi.fn() + + beforeEach( () => { + vi.stubGlobal( 'fetch', fetchMock ) + } ) + + afterEach( () => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + fetchMock.mockReset() + } ) + + it( 'retries transient 5xx errors and eventually resolves JSON', async () => { + fetchMock + .mockResolvedValueOnce( new Response( JSON.stringify({ + ok: false + }), { + headers: { + 'Content-Type': 'application/json' + }, + status: 502 + } ) ) + .mockResolvedValueOnce( new Response( JSON.stringify({ + ok: true + }), { + headers: { + 'Content-Type': 'application/json' + }, + status: 200 + } ) ) + + await expect( getJson( 'https://api.doesitarm.com/sitemap-endpoints.json', { + attempts: 2, + delayMs: 0 + } ) ).resolves.toEqual({ + ok: true + } ) + + expect( fetchMock ).toHaveBeenCalledTimes( 2 ) + } ) + + it( 'does not retry non-5xx errors', async () => { + fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ + ok: false + }), { + headers: { + 'Content-Type': 'application/json' + }, + status: 404 + } ) ) + + await expect( getJson( 'https://api.doesitarm.com/sitemap-endpoints.json', { + attempts: 3, + delayMs: 0 + } ) ).rejects.toMatchObject({ + response: { + status: 404 + } + }) + + expect( fetchMock ).toHaveBeenCalledTimes( 1 ) + } ) + + it( 'returns text responses', async () => { + fetchMock.mockResolvedValueOnce( new Response( '<xml />', { + headers: { + 'Content-Type': 'application/xml' + }, + status: 200 + } ) ) + + await expect( getText( 'https://doesitarm.com/sitemap.xml' ) ).resolves.toBe( '<xml />' ) + } ) + + it( 'posts JSON payloads and parses JSON responses', async () => { + fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ + supportedVersionNumber: '2.1.0' + }), { + headers: { + 'Content-Type': 'application/json' + }, + status: 200 + } ) ) + + await expect( postJson( 'https://doesitarm.com/api/test-results', { + filename: 'App.zip' + } ) ).resolves.toEqual({ + supportedVersionNumber: '2.1.0' + } ) + + expect( fetchMock ).toHaveBeenCalledWith( + 'https://doesitarm.com/api/test-results', + expect.objectContaining({ + body: JSON.stringify({ + filename: 'App.zip' + }), + method: 'POST' + }) + ) + } ) + + it( 'supports config-object JSON requests for the API client', async () => { + fetchMock.mockResolvedValueOnce( new Response( JSON.stringify({ + ok: true + }), { + headers: { + 'Content-Type': 'application/json' + }, + status: 200 + } ) ) + + await expect( requestJson({ + method: 'GET', + url: 'https://doesitarm.com/api/app/spotify.json' + }) ).resolves.toEqual({ + ok: true + } ) + } ) + + it( 'maps head requests to booleans', async () => { + fetchMock + .mockResolvedValueOnce( new Response( null, { + status: 200 + } ) ) + .mockResolvedValueOnce( new Response( null, { + status: 404 + } ) ) + + await expect( headOk( 'https://doesitarm.com/sitemap.xml' ) ).resolves.toBe( true ) + await expect( headOk( 'https://doesitarm.com/missing.xml' ) ).resolves.toBe( false ) + } ) + + it( 'classifies retryable errors by HTTP status', () => { + expect( shouldRetryError( { + response: { + status: 502 + } + } ) ).toBe( true ) + expect( shouldRetryError( { + response: { + status: 503 + } + } ) ).toBe( true ) + expect( shouldRetryError( { + response: { + status: 404 + } + } ) ).toBe( false ) + expect( shouldRetryError( new Error( 'network' ) ) ).toBe( false ) + } ) +} ) diff --git a/test/prebuild/load-sitemap-endpoints.test.ts b/test/prebuild/load-sitemap-endpoints.test.ts index 48c4f4f..1d1a24d 100644 --- a/test/prebuild/load-sitemap-endpoints.test.ts +++ b/test/prebuild/load-sitemap-endpoints.test.ts @@ -6,89 +6,63 @@ import { vi } from 'vitest' -import axios from 'axios' +import fs from 'fs-extra' import { - fetchJsonWithRetries, - shouldRetryError + loadSitemapEndpoints } from '~/helpers/pagefind/load-sitemap-endpoints' +import { getJson } from '~/helpers/http.js' -vi.mock( 'axios', () => { +vi.mock( 'fs-extra', () => { return { default: { - get: vi.fn() + pathExists: vi.fn(), + readJson: vi.fn() } } } ) -describe( 'load sitemap endpoints helper', () => { +vi.mock( '~/helpers/http.js', () => { + return { + getJson: vi.fn(), + shouldRetryError: vi.fn() + } +} ) + +describe( 'load sitemap endpoints', () => { beforeEach( () => { - vi.mocked( axios.get ).mockReset() + vi.mocked( fs.pathExists ).mockReset() + vi.mocked( fs.readJson ).mockReset() + vi.mocked( getJson ).mockReset() } ) - it( 'retries transient 5xx errors and eventually resolves', async () => { - const axiosGet = vi.mocked( axios.get ) - - axiosGet - .mockRejectedValueOnce({ - response: { - status: 502 - } - }) - .mockResolvedValueOnce({ - data: { - ok: true - } - } ) - - const data = await fetchJsonWithRetries( 'https://api.doesitarm.com/sitemap-endpoints.json', { - attempts: 2, - delayMs: 0 + it( 'reads the local sitemap-endpoints file when it exists', async () => { + vi.mocked( fs.pathExists ).mockResolvedValueOnce( true ) + vi.mocked( fs.readJson ).mockResolvedValueOnce({ + endpoints: [ '/api/app/spotify.json' ] } ) - expect( data ).toEqual({ - ok: true - } ) - expect( axiosGet ).toHaveBeenCalledTimes( 2 ) - } ) - - it( 'does not retry non-5xx errors', async () => { - const axiosGet = vi.mocked( axios.get ) - - axiosGet.mockRejectedValueOnce({ - response: { - status: 404 - } + await expect( loadSitemapEndpoints() ).resolves.toEqual({ + endpoints: [ '/api/app/spotify.json' ] }) - await expect( fetchJsonWithRetries( 'https://api.doesitarm.com/sitemap-endpoints.json', { + expect( getJson ).not.toHaveBeenCalled() + } ) + + it( 'falls back to the remote sitemap-endpoints JSON when the local file is missing', async () => { + vi.mocked( fs.pathExists ).mockResolvedValueOnce( false ) + vi.mocked( getJson ).mockResolvedValueOnce({ + endpoints: [ '/api/app/electron-framework.json' ] + } ) + process.env.PUBLIC_API_DOMAIN = 'https://api.doesitarm.com' + + await expect( loadSitemapEndpoints() ).resolves.toEqual({ + endpoints: [ '/api/app/electron-framework.json' ] + } ) + + expect( getJson ).toHaveBeenCalledWith( 'https://api.doesitarm.com/sitemap-endpoints.json', { attempts: 3, - delayMs: 0 - } ) ).rejects.toEqual({ - response: { - status: 404 - } + delayMs: 1000 }) - - expect( axiosGet ).toHaveBeenCalledTimes( 1 ) - } ) - - it( 'classifies retryable server errors', () => { - expect( shouldRetryError( { - response: { - status: 502 - } - } ) ).toBe( true ) - expect( shouldRetryError( { - response: { - status: 503 - } - } ) ).toBe( true ) - expect( shouldRetryError( { - response: { - status: 404 - } - } ) ).toBe( false ) - expect( shouldRetryError( new Error( 'network' ) ) ).toBe( false ) } ) } ) diff --git a/test/scanner/plist.test.ts b/test/scanner/plist.test.ts index 9fe2f5e..3060e98 100644 --- a/test/scanner/plist.test.ts +++ b/test/scanner/plist.test.ts @@ -1,20 +1,16 @@ import { Buffer } from 'buffer' -import { - describe, - expect, - it, - vi -} from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { - parseFileSync, - parsePlistBuffer + parseFileSync, + parsePlistBuffer, } from '~/helpers/scanner/parsers/plist-parser' type ParsedPlist = Record<string, string> -const xmlPlist = Buffer.from( [ +const xmlPlist = Buffer.from( + [ '<?xml version="1.0" encoding="UTF-8"?>', '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">', '<plist version="1.0">', @@ -24,29 +20,34 @@ const xmlPlist = Buffer.from( [ ' <key>CFBundleIdentifier</key>', ' <string>com.doesitarm.playwright-native-app</string>', '</dict>', - '</plist>' -].join( '\n' ), 'utf8' ) + '</plist>', + ].join('\n'), + 'utf8', +) -describe( 'plist parser', () => { - it( 'parses xml plist buffers asynchronously', async () => { - const callback = vi.fn() - const plist = await parsePlistBuffer( xmlPlist as any, callback ) as ParsedPlist +describe('plist parser', () => { + it('parses xml plist buffers asynchronously', async () => { + const callback = vi.fn() + const plist = (await parsePlistBuffer( + xmlPlist as any, + callback, + )) as ParsedPlist - expect( plist.CFBundleExecutable ).toBe( 'Playwright Native App' ) - expect( plist.CFBundleIdentifier ).toBe( 'com.doesitarm.playwright-native-app' ) - expect( callback ).toHaveBeenCalledWith( null, plist ) - } ) + expect(plist.CFBundleExecutable).toBe('Playwright Native App') + expect(plist.CFBundleIdentifier).toBe('com.doesitarm.playwright-native-app') + expect(callback).toHaveBeenCalledWith(null, plist) + }) - it( 'parses xml plist buffers synchronously', () => { - const plist = parseFileSync( xmlPlist as any ) as ParsedPlist + it('parses xml plist buffers synchronously', () => { + const plist = parseFileSync(xmlPlist as any) as ParsedPlist - expect( plist.CFBundleExecutable ).toBe( 'Playwright Native App' ) - expect( plist.CFBundleIdentifier ).toBe( 'com.doesitarm.playwright-native-app' ) - } ) + expect(plist.CFBundleExecutable).toBe('Playwright Native App') + expect(plist.CFBundleIdentifier).toBe('com.doesitarm.playwright-native-app') + }) - it( 'rejects invalid plist data', async () => { - await expect( parsePlistBuffer( Buffer.from( 'not-a-plist', 'utf8' ) as any ) ) - .rejects - .toThrow( /Invalid binary plist/i ) - } ) -} ) + it('rejects invalid plist data', async () => { + await expect( + parsePlistBuffer(Buffer.from('not-a-plist', 'utf8') as any), + ).rejects.toThrow(/Invalid binary plist/i) + }) +}) From b6e87d7e4676250a85fd1c857d89759d800a0684 Mon Sep 17 00:00:00 2001 From: ThatGuySam <sam@sam.lc> Date: Mon, 6 Apr 2026 12:22:24 -0500 Subject: [PATCH 5/7] docs: add trunk-based development guidance Record repo guidance to perform trunk-based development on master and avoid long-lived feature branches. --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a48c6d2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# AGENTS Instructions + +## Development Model + +- Use trunk-based development. +- Make changes directly on `master`. +- Do not create or rely on long-lived feature branches. From fd3ade1ad9d4aba26e323c88d7ce03583a696596 Mon Sep 17 00:00:00 2001 From: ThatGuySam <sam@sam.lc> Date: Mon, 6 Apr 2026 12:26:21 -0500 Subject: [PATCH 6/7] gitignore: ignore docs/data for OMX workspace artifacts Keep untracked so OMX-driven planning and research data remains local while keeping history clean. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 191b622..cbaa1d3 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ dist /.vscode/snipsnap.code-snippets .vercel .env*.local + +# Keep plan/search artifacts for OMX workflows +docs/data/ From a05f8607b81f71502fdc80b7189432c923098aaa Mon Sep 17 00:00:00 2001 From: ThatGuySam <sam@sam.lc> Date: Mon, 6 Apr 2026 12:26:27 -0500 Subject: [PATCH 7/7] docs: record lockfile outcome after axios removal Complete the axios-removal plan note that only transitive gaxios remains in lockfile and document policy decision for merge completion. --- docs/plans/axios-removal.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plans/axios-removal.md b/docs/plans/axios-removal.md index d6adf72..14d55e3 100644 --- a/docs/plans/axios-removal.md +++ b/docs/plans/axios-removal.md @@ -30,6 +30,8 @@ testable HTTP layer instead of many ad hoc call-site rewrites. - execute `pnpm run netlify-build` ✅ - run live deploy verification (`apple-silicon-app-test` URLs) ✅ - confirm whether `pnpm-lock.yaml` changes are sufficient for your lockfile policy + - ✅ `package.json` no longer lists axios + - ✅ `pnpm-lock.yaml` still contains only transitive `gaxios` (Google API client dependency), no direct `axios` entry # Non-Goals