From 07a6b8a21edefeafc74fd07a9470c6b4d624c990 Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Wed, 19 Aug 2026 15:38:41 +0100 Subject: [PATCH 1/4] feat: run cloned Next.js repos on webpack instead of Turbopack --- src/lib/ide/native-deps.ts | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts index db42cca..3c74f89 100644 --- a/src/lib/ide/native-deps.ts +++ b/src/lib/ide/native-deps.ts @@ -11,6 +11,9 @@ type Manifest = { /** What the repo declares, package name to version spec, dependencies and devDependencies. */ type DeclaredDeps = Map; +/** Reads the repo's own value for an entry, never an earlier patch's, so rule order cannot matter. */ +type CurrentValue = (section: string, name: string) => string | undefined; + type SectionPatch = { /** Top-level manifest key holding a name to value map. */ section: string; @@ -21,7 +24,7 @@ type SectionPatch = { type FrameworkRule = { applies: (deps: DeclaredDeps) => boolean; - patches: SectionPatch[]; + patches: (deps: DeclaredDeps, current: CurrentValue) => SectionPatch[]; }; /** Ours wins: for where the repo's own value is the thing that breaks the pod. */ @@ -51,15 +54,36 @@ const FRAMEWORK_RULES: FrameworkRule[] = [ // Vite 8+ { applies: (deps) => majorAtLeast(deps, 'vite', 8), - patches: [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.2' })] + patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.2' })] }, // Vite 7 and earlier { applies: (deps) => majorBelow(deps, 'vite', 8), - patches: [force('overrides', WASM_BUNDLERS)] + patches: () => [force('overrides', WASM_BUNDLERS)] + }, + // Next.js + { + applies: (deps) => deps.has('next'), + patches: (deps, current) => { + const dev = nextDevWithWebpack(current('scripts', 'dev'), deps); + return dev ? [force('scripts', { dev })] : []; + } } ]; +/** + * Turbopack needs native bindings the pod cannot execute. Dropping its flags is enough through Next + * 15; 16 defaults to it and needs `--webpack` to opt out. + */ +function nextDevWithWebpack(script: string | undefined, deps: DeclaredDeps): string | undefined { + if (!script) return undefined; + const withoutTurbopack = script.replace(/ --turbo(?:pack)?\b/g, ''); + if (!majorAtLeast(deps, 'next', 16) || withoutTurbopack.includes('--webpack')) { + return withoutTurbopack; + } + return withoutTurbopack.replace(/\bnext\s+dev\b/, '$& --webpack'); +} + /** Highest major the spec could install. Null means no ceiling at all. */ function highestMajor(spec: string): number | null { if (spec.includes('>') && !spec.includes('<')) return null; @@ -116,6 +140,10 @@ export function patchClonedManifest( const deps: DeclaredDeps = new Map( Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }) ); + const currentValue: CurrentValue = (section, name) => { + const held = manifest[section]; + return isRecord(held) ? held[name] : undefined; + }; // Working copies, seeded from the manifest the first time a rule touches the section. A section // holding anything other than a map is treated as absent; npm would reject it anyway. const sections = new Map>(); @@ -132,7 +160,7 @@ export function patchClonedManifest( const notes: string[] = []; for (const rule of FRAMEWORK_RULES) { if (!rule.applies(deps)) continue; - for (const { section: sectionName, mode, entries } of rule.patches) { + for (const { section: sectionName, mode, entries } of rule.patches(deps, currentValue)) { const section = workingCopy(sectionName); for (const [name, value] of Object.entries(entries)) { if (mode === 'fill' && alreadyPresent(section, sectionName, name, deps)) continue; From 373f0d8d74cd8d6a58160e4a9b2a4156303e9e0f Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Mon, 24 Aug 2026 09:11:30 +0100 Subject: [PATCH 2/4] fix: apply the rolldown wasm binding only from Vite 8.2 --- src/lib/ide/native-deps.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts index 3c74f89..5b838f2 100644 --- a/src/lib/ide/native-deps.ts +++ b/src/lib/ide/native-deps.ts @@ -45,16 +45,17 @@ const DEP_SECTIONS = new Set([ 'peerDependencies' ]); +/** Native binaries the pod cannot execute. */ const WASM_BUNDLERS = { - esbuild: 'npm:esbuild-wasm@0.25.11', - rollup: 'npm:@rollup/wasm-node@4.52.4' + esbuild: 'npm:esbuild-wasm@*', + rollup: 'npm:@rollup/wasm-node@*' }; const FRAMEWORK_RULES: FrameworkRule[] = [ - // Vite 8+ + // Vite 8.2+ { - applies: (deps) => majorAtLeast(deps, 'vite', 8), - patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.2' })] + applies: (deps) => minorAtLeast(deps, 'vite', 8, 2), + patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.5' })] }, // Vite 7 and earlier { @@ -92,6 +93,27 @@ function highestMajor(spec: string): number | null { return Math.max(...versions.map((version) => Number.parseInt(version, 10))); } +/** Highest minor the spec could install within `major`, Infinity when it leaves the minor free. */ +function highestMinor(spec: string, major: number): number { + // A caret or a bare major floats the minor: `^8.1.1` installs 8.2 today, so it reads as 8.x. + if (spec.includes('^')) return Infinity; + const minors = (spec.match(/\d+(?:\.\d+)*/g) ?? []) + .map((version) => version.split('.').map(Number)) + .filter(([declared]) => declared === major) + .map(([, minor]) => minor ?? Infinity); + return minors.length > 0 ? Math.max(...minors) : Infinity; +} + +/** True when `name` is declared and can install `major.minor` or newer. */ +function minorAtLeast(deps: DeclaredDeps, name: string, major: number, minor: number): boolean { + const spec = deps.get(name); + if (spec === undefined) return false; + const highest = highestMajor(spec); + if (highest === null) return true; + if (highest !== major) return highest > major; + return highestMinor(spec, major) >= minor; +} + /** True when `name` is declared and can install `major` or newer, an unversioned spec included. */ function majorAtLeast(deps: DeclaredDeps, name: string, major: number): boolean { const spec = deps.get(name); From 5fd0f0a2dacce8a95842a5f2d586cb3cd4f7b059 Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Tue, 25 Aug 2026 17:27:42 +0100 Subject: [PATCH 3/4] fix: add --webpack to a bare dev script --- src/lib/ide/native-deps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts index 5b838f2..9f6dde9 100644 --- a/src/lib/ide/native-deps.ts +++ b/src/lib/ide/native-deps.ts @@ -82,7 +82,7 @@ function nextDevWithWebpack(script: string | undefined, deps: DeclaredDeps): str if (!majorAtLeast(deps, 'next', 16) || withoutTurbopack.includes('--webpack')) { return withoutTurbopack; } - return withoutTurbopack.replace(/\bnext\s+dev\b/, '$& --webpack'); + return withoutTurbopack.replace(/\bnext\b(?:\s+dev\b)?(?!\s+[a-z])/, '$& --webpack'); } /** Highest major the spec could install. Null means no ceiling at all. */ From 5e3d544a9d66a674f4dafb771fc3ce68ef3bd4d5 Mon Sep 17 00:00:00 2001 From: rijulshrestha Date: Wed, 26 Aug 2026 15:07:08 +0100 Subject: [PATCH 4/4] feat: install cloned Nuxt 4 repos with --legacy-peer-deps --- src/lib/ide/native-deps.ts | 50 ++++++++++++++++++++++++++--------- src/lib/ide/session.svelte.ts | 18 +++++++++++-- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts index 9f6dde9..cb9f207 100644 --- a/src/lib/ide/native-deps.ts +++ b/src/lib/ide/native-deps.ts @@ -1,5 +1,6 @@ /** - * Per-framework patches applied to a cloned GitHub repo's package.json before install. + * Per-framework patches applied to a cloned GitHub repo's package.json before install, and the + * `npm install` flags it needs. */ type Manifest = { @@ -24,7 +25,8 @@ type SectionPatch = { type FrameworkRule = { applies: (deps: DeclaredDeps) => boolean; - patches: (deps: DeclaredDeps, current: CurrentValue) => SectionPatch[]; + patches?: (deps: DeclaredDeps, current: CurrentValue) => SectionPatch[]; + installFlags?: string[]; }; /** Ours wins: for where the repo's own value is the thing that breaks the pod. */ @@ -69,6 +71,11 @@ const FRAMEWORK_RULES: FrameworkRule[] = [ const dev = nextDevWithWebpack(current('scripts', 'dev'), deps); return dev ? [force('scripts', { dev })] : []; } + }, + // Nuxt 4+ + { + applies: (deps) => majorAtLeast(deps, 'nuxt', 4), + installFlags: ['--legacy-peer-deps'] } ]; @@ -130,6 +137,18 @@ function majorBelow(deps: DeclaredDeps, name: string, major: number): boolean { return highest !== null && highest < major; } +function parseManifest(manifestRaw: string): Manifest | null { + try { + return JSON.parse(manifestRaw) as Manifest; + } catch { + return null; + } +} + +function declaredDeps(manifest: Manifest): DeclaredDeps { + return new Map(Object.entries({ ...manifest.dependencies, ...manifest.devDependencies })); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -153,15 +172,9 @@ function alreadyPresent( export function patchClonedManifest( manifestRaw: string ): { patched: string; notes: string[] } | null { - let manifest: Manifest; - try { - manifest = JSON.parse(manifestRaw) as Manifest; - } catch { - return null; - } - const deps: DeclaredDeps = new Map( - Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }) - ); + const manifest = parseManifest(manifestRaw); + if (!manifest) return null; + const deps = declaredDeps(manifest); const currentValue: CurrentValue = (section, name) => { const held = manifest[section]; return isRecord(held) ? held[name] : undefined; @@ -181,7 +194,7 @@ export function patchClonedManifest( const notes: string[] = []; for (const rule of FRAMEWORK_RULES) { - if (!rule.applies(deps)) continue; + if (!rule.applies(deps) || !rule.patches) continue; for (const { section: sectionName, mode, entries } of rule.patches(deps, currentValue)) { const section = workingCopy(sectionName); for (const [name, value] of Object.entries(entries)) { @@ -201,3 +214,16 @@ export function patchClonedManifest( const patched = JSON.stringify(manifest, null, indent) + (manifestRaw.endsWith('\n') ? '\n' : ''); return { patched, notes }; } + +/** Deduplicated so two matching rules asking for the same flag pass it once. */ +export function resolveInstallArgs(manifestRaw: string): string[] { + const manifest = parseManifest(manifestRaw); + if (!manifest) return ['install']; + const deps = declaredDeps(manifest); + const flags = new Set(); + for (const rule of FRAMEWORK_RULES) { + if (!rule.applies(deps)) continue; + for (const flag of rule.installFlags ?? []) flags.add(flag); + } + return ['install', ...flags]; +} diff --git a/src/lib/ide/session.svelte.ts b/src/lib/ide/session.svelte.ts index b0d218f..4a1d04d 100644 --- a/src/lib/ide/session.svelte.ts +++ b/src/lib/ide/session.svelte.ts @@ -14,7 +14,7 @@ import { writeToTerminal } from '$lib/pod/fs'; import { ANSI, BP_RC, BP_RC_PATH } from './shell-rc'; -import { patchClonedManifest } from './native-deps'; +import { patchClonedManifest, resolveInstallArgs } from './native-deps'; import { fetchRepoTree } from '$lib/github/api'; import { trackEvent } from '$lib/utils/useLazyTracking'; import type { PortalUpdate } from '$lib/pod/portals'; @@ -234,7 +234,9 @@ export class IdeSession { trackEvent('Booted Playground GitHub', { repo: `${owner}/${repo}` }); this.bootStage = 'installing'; - await this.runInOutput('npm', ['install']); + const installArgs = await this.installArgs(); + if (this.cancelled(token)) return; + await this.runInOutput('npm', installArgs); if (this.cancelled(token)) return; const script = await this.resolveStartScript(); @@ -359,6 +361,18 @@ export class IdeSession { } } + /** A read failure falls back to a plain install. */ + private async installArgs(): Promise { + if (!this.pod) return ['install']; + try { + const raw = await readPodFile(this.pod, `${this.workdir}/package.json`); + return resolveInstallArgs(raw); + } catch (error) { + console.error('Failed to read package.json:', error); + return ['install']; + } + } + /** Pick the dev-server script from the cloned package.json: prefer `dev`, then `start`. */ private async resolveStartScript(): Promise { if (!this.pod) return null;