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
57 changes: 43 additions & 14 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 @@ -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. */
Expand All @@ -51,11 +53,13 @@ const WASM_BUNDLERS = {
rollup: 'npm:@rollup/wasm-node@*'
};

const ROLLDOWN_WASM = { '@rolldown/binding-wasm32-wasi': '1.2.5' };

const FRAMEWORK_RULES: FrameworkRule[] = [
// Vite 8.2+
{
applies: (deps) => minorAtLeast(deps, 'vite', 8, 2),
patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.5' })]
patches: () => [fill('devDependencies', ROLLDOWN_WASM)]
},
// Vite 7 and earlier
{
Expand All @@ -69,6 +73,12 @@ const FRAMEWORK_RULES: FrameworkRule[] = [
const dev = nextDevWithWebpack(current('scripts', 'dev'), deps);
return dev ? [force('scripts', { dev })] : [];
}
},
// Nuxt 4+
{
applies: (deps) => majorAtLeast(deps, 'nuxt', 4),
patches: () => [fill('dependencies', ROLLDOWN_WASM), force('overrides', WASM_BUNDLERS)],
installFlags: ['--legacy-peer-deps']
}
];

Expand All @@ -82,7 +92,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. */
Expand Down Expand Up @@ -130,6 +140,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 @@ -153,15 +175,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;
Expand All @@ -181,7 +197,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)) {
Expand All @@ -201,3 +217,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];
}
20 changes: 15 additions & 5 deletions src/lib/ide/repo-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { fetchRepoTree } from '$lib/github/api';
import { POD_HOME, readPodFile, writePodFile } from '$lib/pod/fs';
import { trackEvent } from '$lib/utils/useLazyTracking';
import { patchClonedManifest } from './native-deps';
import { patchClonedManifest, resolveInstallArgs } from './native-deps';
import { ANSI } from './shell-rc';
import type { BootContext, ProjectSource } from './project-source';

Expand All @@ -11,6 +11,8 @@ export type RepoRef = { owner: string; repo: string; ref: string; dir: string };
export function repoSource({ owner, repo, ref, dir }: RepoRef): ProjectSource {
const url = `https://github.com/${owner}/${repo}`;
const repoDir = `${POD_HOME}/${repo}`;
// Filled by prepare(), which reads the manifest install flags are resolved from.
let installArgs: string[] = ['install'];

return {
id: 'github',
Expand All @@ -33,32 +35,40 @@ export function repoSource({ owner, repo, ref, dir }: RepoRef): ProjectSource {
color: false
}),
// Patched before the first tab opens, so the editor shows the manifest install will see.
prepare: patchManifest,
prepare: async (ctx: BootContext): Promise<void> => {
installArgs = await prepareManifest(ctx);
},
initialFile: (files: string[]) => files[0],
installCommands: () => [['install']],
installCommands: () => [installArgs],
startCommand: resolveStartScript,
trackBoot: () => trackEvent('Booted Playground GitHub', { repo: `${owner}/${repo}` })
};
}

async function patchManifest(ctx: BootContext): Promise<void> {
/**
* Patches the cloned manifest and returns the `npm install` args for it. Both come from the one
* read, so install does not re-read the file prepare() just wrote.
*/
async function prepareManifest(ctx: BootContext): Promise<string[]> {
const manifestPath = `${ctx.workdir}/package.json`;
try {
const raw = await readPodFile(ctx.pod, manifestPath);
const result = patchClonedManifest(raw);
if (!result) {
ctx.write(`\r\n${ANSI.dim}No dependency patches apply to this repo.${ANSI.reset}\r\n`);
return;
return resolveInstallArgs(raw);
}
await writePodFile(ctx.pod, manifestPath, result.patched);
// Header takes the leading blank line; the changes follow indented under it as one block.
ctx.write(`\r\n${ANSI.dim}Modified package.json${ANSI.reset}\r\n`);
for (const note of result.notes) ctx.write(`${ANSI.dim} ${note}${ANSI.reset}\r\n`);
return resolveInstallArgs(result.patched);
} catch (error) {
ctx.write(
`\r\n${ANSI.coral}Could not patch package.json; installing the repo as cloned.${ANSI.reset}\r\n`
);
console.warn('Could not patch the cloned manifest:', error);
return ['install'];
}
}

Expand Down