Skip to content
Draft
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
114 changes: 95 additions & 19 deletions src/lib/ide/native-deps.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -11,6 +12,9 @@ type Manifest = {
/** What the repo declares, package name to version spec, dependencies and devDependencies. */
type DeclaredDeps = Map<string, string>;

/** 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;
Expand All @@ -21,7 +25,8 @@ type SectionPatch = {

type FrameworkRule = {
applies: (deps: DeclaredDeps) => boolean;
patches: 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. */
Expand All @@ -42,24 +47,51 @@ 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
{
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 })] : [];
}
},
// Nuxt 4+
{
applies: (deps) => majorAtLeast(deps, 'nuxt', 4),
installFlags: ['--legacy-peer-deps']
}
];

/**
* 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\b(?:\s+dev\b)?(?!\s+[a-z])/, '$& --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;
Expand All @@ -68,6 +100,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);
Expand All @@ -84,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<string, string> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Expand All @@ -107,15 +172,13 @@ 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;
};
// 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<string, Record<string, string>>();
Expand All @@ -131,8 +194,8 @@ 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) {
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)) {
if (mode === 'fill' && alreadyPresent(section, sectionName, name, deps)) continue;
Expand All @@ -151,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<string>();
for (const rule of FRAMEWORK_RULES) {
if (!rule.applies(deps)) continue;
for (const flag of rule.installFlags ?? []) flags.add(flag);
}
return ['install', ...flags];
}
18 changes: 16 additions & 2 deletions src/lib/ide/session.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -359,6 +361,18 @@ export class IdeSession {
}
}

/** A read failure falls back to a plain install. */
private async installArgs(): Promise<string[]> {
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<string | null> {
if (!this.pod) return null;
Expand Down