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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/node-modules-inspector/src/app/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,24 @@ export function createRegistryBackend(
})
registryWarnings.value = warnings

// If the user asked for top-level packages but none of them could be
// resolved, treat it as a hard failure. Throwing here propagates to the
// landing page's error box (via `fetchData(..., propagateError)`),
// instead of dropping the user into an empty graph with only a toast.
const requestedCount = Object.keys(dependencies).length
const root = [...result.packages.values()].find(pkg => pkg.workspace)
const resolvedTopLevel = root?.dependencies.size ?? 0
if (requestedCount && !resolvedTopLevel) {
const reasons = warnings
.filter(warning => !warning.dependent)
.map(warning => warning.message)
throw new Error(
reasons.length
? reasons.join('\n')
: 'None of the requested packages could be resolved from the npm registry.',
)
}

return {
hash: getHash([...result.packages.keys()].sort()),
timestamp: Date.now(),
Expand Down
26 changes: 22 additions & 4 deletions packages/node-modules-inspector/src/app/web/Landing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
import type { InstallExcludeSpec } from 'node-modules-tools/registry'
import { parseInstallSpecs } from 'node-modules-tools/registry'
import { computed, defineAsyncComponent, onMounted, ref, shallowRef } from 'vue'
import { useRouter } from '#app/composables/router'
import { backend } from '../backends'
import RegistryWarnings from '../components/registry/Warnings.vue'
import UiCredits from '../components/ui/Credits.vue'
import UiTitle from '../components/ui/Title.vue'
import MainEntry from '../entries/main.vue'
import { createRegistryBackend, registryProgress } from '../registry'
import { fetchData, rawPayload } from '../state/data'
import { fetchData } from '../state/data'
import { query } from '../state/query'
import { openTerminal, showTerminal } from '../state/terminal'

Expand All @@ -18,6 +19,11 @@ const LazyPanelTerminal = defineAsyncComponent(() => import('../components/panel

type WebMode = 'instant' | 'sandbox'

const router = useRouter()
// `Landing` renders outside `<NuxtPage>`, where `useRoute()` returns a stale
// snapshot — `router.currentRoute` is the reactive source that tracks pushes.
const isLanding = computed(() => router.currentRoute.value.path === '/')

// The WebContainer SDK (`@webcontainer/api`) is heavy and only needed for
// "Sandbox Install" mode. Load `./container` lazily so the default Instant
// (npm-registry) mode never pulls the SDK into the initial bundle.
Expand Down Expand Up @@ -147,6 +153,13 @@ async function run() {
await runSandbox(deps)
else
await runInstant(deps)

// Navigate into the inspector with a real history push (not a replace) so
// the browser Back button returns here to the landing. The landing lives
// at `/`; once we're on an inspector route the payload renders MainEntry.
// Only move on a clean success — stay put if anything went wrong.
if (isLanding.value && !error.value)
await router.push({ path: '/grid/depth', hash: location.hash })
}
catch (e) {
console.error(e)
Expand Down Expand Up @@ -187,7 +200,12 @@ async function runSandbox(deps: Record<string, string>) {
</script>

<template>
<template v-if="!backend || !rawPayload">
<!--
The landing lives at `/`. Gating on the route (rather than payload presence)
means Back-navigating to `/` shows the landing again, even though the
resolved payload is still in memory.
-->
<template v-if="isLanding">
<div
flex="~ col items-center gap-5" p10
@dragover.prevent="isDragging = true"
Expand Down Expand Up @@ -291,8 +309,8 @@ async function runSandbox(deps: Record<string, string>) {
<div font-bold>
{{ mode === 'sandbox' ? 'Failed to Connect to the Backend' : 'Failed to Resolve Dependencies' }}
</div>
<div text-red5 dark:text-red3>
{{ error }}
<div text-red5 dark:text-red3 text-center whitespace-pre-line>
{{ error?.message || error }}
</div>
</div>

Expand Down
37 changes: 37 additions & 0 deletions packages/node-modules-tools/src/registry/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,43 @@ describe('resolveRegistryDependencies', () => {
expect(result.warnings.map(w => w.type).sort()).toEqual(['fetch-error', 'unresolved-version'])
})

it('resolves dependency cycles without deadlocking', async () => {
// A cycle where the same `name@range` recurs on the back-edge used to
// deadlock: a node's spec was only returned after its whole subtree
// expanded, so a cyclic edge back to the in-flight node awaited itself.
const { fetch } = createMockRegistry({
a: { versions: { '1.0.0': { dependencies: { b: '^1.0.0' } } } },
b: { versions: { '1.0.0': { dependencies: { c: '^1.0.0' } } } },
c: { versions: { '1.0.0': { dependencies: { a: '^1.0.0' } } } },
})

const result = await resolveRegistryDependencies({
dependencies: { a: '^1.0.0' },
fetch,
})

expect(specs(result)).toEqual(['a@1.0.0', 'b@1.0.0', 'c@1.0.0', ROOT_SPEC])
expect([...result.packages.get('a@1.0.0')!.dependencies]).toEqual(['b@1.0.0'])
expect([...result.packages.get('b@1.0.0')!.dependencies]).toEqual(['c@1.0.0'])
expect([...result.packages.get('c@1.0.0')!.dependencies]).toEqual(['a@1.0.0'])
expect(result.warnings).toEqual([])
})

it('resolves a self-referential dependency without deadlocking', async () => {
const { fetch } = createMockRegistry({
a: { versions: { '1.0.0': { dependencies: { a: '^1.0.0', b: '^1.0.0' } } } },
b: { versions: { '1.0.0': {} } },
})

const result = await resolveRegistryDependencies({
dependencies: { a: '^1.0.0' },
fetch,
})

expect(specs(result)).toEqual(['a@1.0.0', 'b@1.0.0', ROOT_SPEC])
expect([...result.packages.get('a@1.0.0')!.dependencies].sort()).toEqual(['a@1.0.0', 'b@1.0.0'])
})

it('respects the depth limit', async () => {
const { fetch } = createMockRegistry({
d1: { versions: { '1.0.0': { dependencies: { d2: '*' } } } },
Expand Down
34 changes: 29 additions & 5 deletions packages/node-modules-tools/src/registry/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ interface PeerTask {
depth: number
}

interface ExpandTask {
node: PackageNodeRaw
meta: RegistryAbbreviatedVersion
depth: number
}

/**
* Resolve a dependency graph purely from npm-registry metadata — no package
* manager, no filesystem. Produces the same result shape as
Expand Down Expand Up @@ -61,6 +67,12 @@ export async function resolveRegistryDependencies(
const warnings: RegistryResolveWarning[] = []
const resolutions = new Map<string, Promise<string | null>>()
const peerTasks: PeerTask[] = []
// Expansion of a node's subtree is deferred and drained to a fixpoint by the
// driver below, rather than awaited inline while resolving the node. This is
// what keeps dependency cycles from deadlocking: a node's spec is returned as
// soon as its version is picked, so a cyclic edge back to an in-flight
// `name@range` resolves immediately instead of awaiting its own subtree.
const expandQueue: ExpandTask[] = []

function warn(warning: RegistryResolveWarning) {
warnings.push(warning)
Expand Down Expand Up @@ -178,8 +190,10 @@ export async function resolveRegistryDependencies(
versionsByName.get(name)!.add(version)
reportResolving()

// Defer expansion so this promise resolves to `spec` right away —
// see `expandQueue` above for why this avoids cycle deadlocks.
if (depth < maxDepth)
await expandNode(node, meta, depth)
expandQueue.push({ node, meta, depth })
}
return spec
})())
Expand Down Expand Up @@ -249,10 +263,20 @@ export async function resolveRegistryDependencies(
}
}))

// Auto-installed peers may bring their own dependencies and peers — iterate to fixpoint
while (peerTasks.length) {
const batch = peerTasks.splice(0)
await Promise.all(batch.map(task => resolvePeer(task)))
// Drain the deferred subtree expansions, then any auto-installed peers.
// Both may enqueue further work (expansions bring more deps and peers; peers
// may auto-install packages with their own subtrees), so iterate to a
// fixpoint. Expansions are fully drained before peers so a peer is matched
// against the complete non-peer graph (npm 7+ behavior).
while (expandQueue.length || peerTasks.length) {
if (expandQueue.length) {
const batch = expandQueue.splice(0)
await Promise.all(batch.map(task => expandNode(task.node, task.meta, task.depth)))
}
else {
const batch = peerTasks.splice(0)
await Promise.all(batch.map(task => resolvePeer(task)))
}
}

// Synthetic workspace root holding the inputs (hidden by the default workspace filter)
Expand Down
25 changes: 25 additions & 0 deletions test/e2e/instant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,29 @@ test.describe('hosted instant mode', () => {
await expect(page.getByText('approximate', { exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: 'npm registry' })).toBeVisible()
})

test('shows an error on the landing when the query cannot be resolved', async ({ page }) => {
await mockRegistry(page)

// `ghost-pkg` is not in the fixtures, so the registry returns 404 for it.
await page.goto('/#install=ghost-pkg')

// The landing stays put and surfaces the failure — no empty graph.
await expect(page.getByText('Failed to Resolve Dependencies')).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('button', { name: 'Registry Query', exact: true })).toBeVisible()
await expect(page.locator('a[href^="/grid"]')).toHaveCount(0)
})

test('browser Back returns to the landing after a query', async ({ page }) => {
await mockRegistry(page)

await page.goto('/#install=demo-lib')
await expect(page.locator('a[href^="/grid"]').first()).toBeVisible({ timeout: 30_000 })
// Navigating into the inspector pushed a history entry, not a replace.
await expect(page).toHaveURL(/\/grid\//)

await page.goBack()
await expect(page.getByRole('button', { name: 'Registry Query', exact: true })).toBeVisible()
await expect(page.getByPlaceholder('Enter package names')).toBeVisible()
})
})
Loading