UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

151 lines (138 loc) • 6.18 kB
// a browser content blocker (safari's built-in blockers, ublock, adguard, // brave shields) matches filter rules against the request url. in dev the // vite module url mirrors the source path, so an ordinary app file can match // a tracking rule purely by where it lives — easylist, easyprivacy and // adguard all ship the plain substring rule `/analytics/pageview`, which is // enough to refuse `src/features/site/analytics/PageviewTracker.tsx`. // // one blocked module fails the whole dynamic import, and the browser reports // only `TypeError: Importing a module script failed` pointing at one's own // route loader. nothing in that names the file, the app, or the blocker, so // it reads as a framework or vite bug and sends people restarting dev servers // and clearing caches. // // a blocked request also fails `fetch()`, so walking the route's module graph // from the browser finds the exact module the blocker refused. the same walk // names a module the dev server answers 404 for, which the browser reports just // as vaguely: an import of a file that was never written. dev only, and only // after a route has already failed to load. const MAX_MODULES = 500 const TIMEOUT_MS = 5000 // vite rewrites bare specifiers to absolute paths, so most of these are // already root-relative; the rest resolve against the importing module. function collectImports(source: string, base: string): string[] { const out: string[] = [] const add = (spec: string) => { if (!/^[./]|^https?:/.test(spec)) return try { out.push(new URL(spec, base).href) } catch {} } for (const m of source.matchAll( /(?:^|[\s;}])(?:import|export)[^'"]{0,300}?from\s*["']([^"']+)["']/g )) add(m[1]) for (const m of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) add(m[1]) for (const m of source.matchAll(/(?:^|[\s;}])import\s*["']([^"']+)["']/g)) add(m[1]) return out } export type RouteLoadFault = // the browser refused the request: it never reached the dev server | { kind: 'blocked'; url: string } // the dev server answered 404: a module imports a file that does not exist | { kind: 'missing'; url: string; importer: string | null } /** * Walks the module graph under `entryUrl` looking for a module that cannot * load: one the dev server answers 404 for, or one the browser refuses to * fetch. Returns null when every module in the graph is reachable (which means * the import failed for some other reason, such as a syntax error inside one * of them). */ export async function findRouteLoadFault(entryUrl: string): Promise<RouteLoadFault | null> { const deadline = Date.now() + TIMEOUT_MS const importers = new Map<string, string | null>() const entry = new URL(entryUrl, window.location.href).href importers.set(entry, null) let frontier = [entry] let visited = 0 while (frontier.length && visited < MAX_MODULES && Date.now() < deadline) { const batch = frontier.slice(0, 32) frontier = frontier.slice(batch.length) visited += batch.length const results = await Promise.all( batch.map(async (url) => { let res: Response try { res = await fetch(url) } catch { return { url, fault: 'unreachable' as const, imports: [] as string[] } } if (res.status === 404) return { url, fault: 'missing' as const, imports: [] } if (!res.ok) return { url, fault: null, imports: [] } const type = res.headers.get('content-type') ?? '' if (!type.includes('javascript')) return { url, fault: null, imports: [] } return { url, fault: null, imports: collectImports(await res.text(), url) } }) ) for (const result of results) { if (result.fault === 'missing') { // prebundled dependencies resolve their own imports, and the regex scan // can match import-shaped text inside their strings, so a 404 counts // only when app source asked for it. const importer = importers.get(result.url) ?? null if (importer?.includes('/node_modules/')) continue return { kind: 'missing', url: result.url, importer } } if (result.fault === 'unreachable') { // a request that never reached the server is a blocker only while the // server still answers: a page tearing down or a dev server going away // fails every fetch the same way. const serverAnswers = await fetch(window.location.href, { method: 'HEAD' }).then( () => true, () => false ) return serverAnswers ? { kind: 'blocked', url: result.url } : null } for (const next of result.imports) { if (importers.has(next)) continue importers.set(next, result.url) frontier.push(next) } } } return null } /** * Turns a failed route import into a message that names the responsible file. * Returns null when the graph is fully reachable, leaving the original error * as the only report. */ export async function diagnoseRouteLoadFailure( routeId: string, routeUrl: string ): Promise<string | null> { const fault = await findRouteLoadFault(routeUrl) if (!fault) return null const toPath = (url: string) => url.replace(window.location.origin, '') const path = toPath(fault.url) if (fault.kind === 'missing') { return [ `Route "${routeId}" failed to load because ${path} does not exist (the dev server answered 404).`, ``, fault.importer ? `${toPath(fault.importer)} imports it. Create the file or remove the import.` : `Create the file or remove the import.`, ].join('\n') } return [ `Route "${routeId}" failed to load because this browser refused to fetch ${path}`, ``, `That request never reached the dev server, so a content blocker or privacy`, `extension is blocking it. In dev the module URL is the source path, and a`, `path containing a word like "analytics", "pageview" or "track" matches the`, `tracking rules those blockers ship.`, ``, `Rename the file or the directory so its path no longer matches, or allow`, `${window.location.origin} in the blocker.`, ].join('\n') }