From 23155e783137f824a1926bf5722fd67162cabae2 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Sep 2026 16:47:35 +0530 Subject: [PATCH 1/5] fix(nextjs): preserve absolute Windows standalone links safely Signed-off-by: Aman Varshney (cherry picked from commit ac9d53331cbb22d028277addb328e501b8418a63) --- .../nextjs/src/__tests__/assemble.test.ts | 49 +++++++++++++++ .../2-authoring/nextjs/src/control/build.ts | 62 ++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts index 8c2951385..5155f26a8 100644 --- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts @@ -186,6 +186,55 @@ describe('assemble()', () => { expect(result.watch).toContain(source); }, 20_000); + test('rewrites an absolute package link to its staged in-bundle target', async () => { + const root = makeAppRoot(); + const { appRel } = writeNextBuild(root); + const standalone = path.join(root, '.next', 'standalone'); + const source = path.join(root, 'node_modules', 'pg'); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = "pg";\n'); + const linkDir = path.join(standalone, appRel, '.next', 'node_modules'); + fs.mkdirSync(linkDir, { recursive: true }); + fs.symlinkSync(source, path.join(linkDir, 'pg-traced'), 'dir'); + + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-')); + tmpDirs.push(cwd); + const result = await assemble({ + address: 'storefront.web', + cwd, + build: nextjs({ module: moduleUrl(root), appDir: '..' }), + }); + + const bundle = path.join(cwd, '.prisma-composer', 'artifacts', 'storefront.web', 'bundle'); + const bundledTarget = path.join(bundle, 'node_modules', 'pg'); + const bundledLink = path.join(bundle, appRel, '.next', 'node_modules', 'pg-traced'); + expect(fs.lstatSync(bundledLink).isSymbolicLink()).toBe(true); + expect(path.resolve(path.dirname(bundledLink), fs.readlinkSync(bundledLink))).toBe( + bundledTarget, + ); + expect(fs.readFileSync(path.join(bundledTarget, 'index.js'), 'utf8')).toContain('pg'); + expect(result.watch).toContain(fs.realpathSync(source)); + }, 20_000); + + test('rejects an absolute package link outside the declared tracing root', async () => { + const root = makeAppRoot(); + const { appRel } = writeNextBuild(root); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-outside-')); + tmpDirs.push(outside); + fs.writeFileSync(path.join(outside, 'secret.txt'), 'must not ship'); + const linkDir = path.join(root, '.next', 'standalone', appRel, '.next', 'node_modules'); + fs.mkdirSync(linkDir, { recursive: true }); + fs.symlinkSync(outside, path.join(linkDir, 'escaped'), 'dir'); + + await expect( + assemble({ + address: 'storefront.web', + cwd: root, + build: nextjs({ module: moduleUrl(root), appDir: '..' }), + }), + ).rejects.toThrow(/assembled bundle contains a symlink whose target escapes the bundle/); + }, 20_000); + test('refuses a manifest whose app location escapes its tracing root', async () => { const root = makeAppRoot(); writeNextBuild(root); diff --git a/packages/0-framework/2-authoring/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index 2646717a0..7ded2d6c1 100644 --- a/packages/0-framework/2-authoring/nextjs/src/control/build.ts +++ b/packages/0-framework/2-authoring/nextjs/src/control/build.ts @@ -139,6 +139,63 @@ async function collectSymlinks(root: string): Promise { return links; } +/** + * Windows standalone output can contain absolute package links. An absolute + * build-machine path cannot ship, even when its target belongs to Next's + * declared trace root. Stage that exact target at the corresponding bundle + * path, then preserve the link as a relative in-bundle link. + * + * The link is never dereferenced: its target is copied separately and the + * topology remains a link. Targets outside the declared trace root are left + * untouched for the bundle validator to reject. + */ +async function stageAbsoluteStandaloneLinkTargets( + bundleDir: string, + manifest: ServerFilesManifest, +): Promise { + const tracingRoot = manifest.tracingRoot; + if (tracingRoot === undefined || (await lstatIfPresent(tracingRoot)) === undefined) return []; + + const tracedRootReal = await fs.promises.realpath(tracingRoot); + const stagedSources = new Set(); + let staged = true; + while (staged) { + staged = false; + for (const linkPath of await collectSymlinks(bundleDir)) { + const rawTarget = await fs.promises.readlink(linkPath); + if (!path.isAbsolute(rawTarget)) continue; + + let sourceReal: string; + try { + sourceReal = await fs.promises.realpath(linkPath); + } catch { + continue; + } + if (!isWithin(tracedRootReal, sourceReal)) continue; + + const target = path.join(bundleDir, path.relative(tracedRootReal, sourceReal)); + if (!isWithin(bundleDir, target) || target === linkPath) continue; + if (await hasSymlinkAncestor(bundleDir, target)) continue; + if ((await lstatIfPresent(target)) === undefined) { + await fs.promises.mkdir(path.dirname(target), { recursive: true }); + await fs.promises.cp(sourceReal, target, { recursive: true, verbatimSymlinks: true }); + } + + const sourceStat = await fs.promises.stat(sourceReal); + const relativeTarget = path.relative(path.dirname(linkPath), target); + await fs.promises.rm(linkPath, { recursive: true, force: true }); + await fs.promises.symlink( + relativeTarget, + linkPath, + sourceStat.isDirectory() ? 'dir' : 'file', + ); + stagedSources.add(sourceReal); + staged = true; + } + } + return [...stagedSources]; +} + /** In-bundle link targets that the standalone tree does not contain — the * repairs staging has to make. */ async function missingLinkTargets(bundleDir: string): Promise { @@ -236,7 +293,8 @@ export async function assemble(input: AssembleInput): Promise { // links stay links; the packager validates that every target remains inside // the assembled bundle before emitting it into the archive. await copyTreeVerbatim(standaloneRoot, bundleDir); - const stagedLinkTargets = await stageMissingStandaloneLinkTargets(bundleDir, manifest); + const stagedAbsoluteLinkTargets = await stageAbsoluteStandaloneLinkTargets(bundleDir, manifest); + const stagedMissingLinkTargets = await stageMissingStandaloneLinkTargets(bundleDir, manifest); // Staging can make previously dangling directory links repairable. await repairWindowsDirectorySymlinks(bundleDir); @@ -277,7 +335,7 @@ export async function assemble(input: AssembleInput): Promise { return { dir: workDir, entry: path.posix.join('bundle', appRel.split(path.sep).join('/'), 'server.js'), - watch: [standaloneRoot, ...stagedLinkTargets], + watch: [standaloneRoot, ...stagedAbsoluteLinkTargets, ...stagedMissingLinkTargets], }; } From 45ab20ba75a989920ac7507aab1b73996a9e7ad3 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Sep 2026 17:01:27 +0530 Subject: [PATCH 2/5] fix(nextjs): isolate staged absolute link targets Signed-off-by: Aman Varshney (cherry picked from commit eab26b43c455ace3d32d5c5ab1e2055e960f888a) --- .../nextjs/src/__tests__/assemble.test.ts | 37 +++++++++++++++++-- .../2-authoring/nextjs/src/control/build.ts | 33 +++++++++++++---- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts index 5155f26a8..efad20d05 100644 --- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts @@ -206,16 +206,45 @@ describe('assemble()', () => { }); const bundle = path.join(cwd, '.prisma-composer', 'artifacts', 'storefront.web', 'bundle'); - const bundledTarget = path.join(bundle, 'node_modules', 'pg'); const bundledLink = path.join(bundle, appRel, '.next', 'node_modules', 'pg-traced'); + const bundledTarget = path.resolve(path.dirname(bundledLink), fs.readlinkSync(bundledLink)); expect(fs.lstatSync(bundledLink).isSymbolicLink()).toBe(true); - expect(path.resolve(path.dirname(bundledLink), fs.readlinkSync(bundledLink))).toBe( - bundledTarget, - ); + expect(bundledTarget.startsWith(`${bundle}${path.sep}`)).toBe(true); expect(fs.readFileSync(path.join(bundledTarget, 'index.js'), 'utf8')).toContain('pg'); expect(result.watch).toContain(fs.realpathSync(source)); }, 20_000); + test('does not let an occupied bundle path shadow an absolute-link target', async () => { + const root = makeAppRoot(); + const { appRel } = writeNextBuild(root); + const standalone = path.join(root, '.next', 'standalone'); + const source = path.join(root, 'node_modules', 'pg'); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = "original";\n'); + const occupied = path.join(standalone, 'node_modules', 'pg'); + fs.mkdirSync(occupied, { recursive: true }); + fs.writeFileSync(path.join(occupied, 'index.js'), 'module.exports = "shadow";\n'); + const linkDir = path.join(standalone, appRel, '.next', 'node_modules'); + fs.mkdirSync(linkDir, { recursive: true }); + fs.symlinkSync(source, path.join(linkDir, 'pg-traced'), 'dir'); + + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-')); + tmpDirs.push(cwd); + await assemble({ + address: 'storefront.web', + cwd, + build: nextjs({ module: moduleUrl(root), appDir: '..' }), + }); + + const bundle = path.join(cwd, '.prisma-composer', 'artifacts', 'storefront.web', 'bundle'); + const bundledLink = path.join(bundle, appRel, '.next', 'node_modules', 'pg-traced'); + const bundledTarget = path.resolve(path.dirname(bundledLink), fs.readlinkSync(bundledLink)); + expect(fs.readFileSync(path.join(bundledTarget, 'index.js'), 'utf8')).toContain('original'); + expect(fs.readFileSync(path.join(bundle, 'node_modules', 'pg', 'index.js'), 'utf8')).toContain( + 'shadow', + ); + }, 20_000); + test('rejects an absolute package link outside the declared tracing root', async () => { const root = makeAppRoot(); const { appRel } = writeNextBuild(root); diff --git a/packages/0-framework/2-authoring/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index 7ded2d6c1..740bb11a3 100644 --- a/packages/0-framework/2-authoring/nextjs/src/control/build.ts +++ b/packages/0-framework/2-authoring/nextjs/src/control/build.ts @@ -25,6 +25,7 @@ * Paths are file-relative (ADR-0004): `appDir` resolves against * `dirname(build.module)`. */ + import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -139,11 +140,25 @@ async function collectSymlinks(root: string): Promise { return links; } +async function createAbsoluteLinkStagingRoot(bundleDir: string): Promise { + const base = path.join(bundleDir, '.prisma-composer-absolute-links'); + for (let suffix = 0; ; suffix += 1) { + const candidate = suffix === 0 ? base : `${base}-${suffix}`; + try { + await fs.promises.mkdir(candidate); + return candidate; + } catch (error) { + if (error instanceof Error && Reflect.get(error, 'code') === 'EEXIST') continue; + throw error; + } + } +} + /** * Windows standalone output can contain absolute package links. An absolute * build-machine path cannot ship, even when its target belongs to Next's - * declared trace root. Stage that exact target at the corresponding bundle - * path, then preserve the link as a relative in-bundle link. + * declared trace root. Stage that exact target under a fresh, collision-free + * bundle directory, then preserve the link as a relative in-bundle link. * * The link is never dereferenced: its target is copied separately and the * topology remains a link. Targets outside the declared trace root are left @@ -158,6 +173,9 @@ async function stageAbsoluteStandaloneLinkTargets( const tracedRootReal = await fs.promises.realpath(tracingRoot); const stagedSources = new Set(); + const stagedTargets = new Map(); + let stagingRoot: string | undefined; + let nextStagedTarget = 0; let staged = true; while (staged) { staged = false; @@ -173,12 +191,13 @@ async function stageAbsoluteStandaloneLinkTargets( } if (!isWithin(tracedRootReal, sourceReal)) continue; - const target = path.join(bundleDir, path.relative(tracedRootReal, sourceReal)); - if (!isWithin(bundleDir, target) || target === linkPath) continue; - if (await hasSymlinkAncestor(bundleDir, target)) continue; - if ((await lstatIfPresent(target)) === undefined) { - await fs.promises.mkdir(path.dirname(target), { recursive: true }); + let target = stagedTargets.get(sourceReal); + if (target === undefined) { + stagingRoot ??= await createAbsoluteLinkStagingRoot(bundleDir); + target = path.join(stagingRoot, String(nextStagedTarget)); + nextStagedTarget += 1; await fs.promises.cp(sourceReal, target, { recursive: true, verbatimSymlinks: true }); + stagedTargets.set(sourceReal, target); } const sourceStat = await fs.promises.stat(sourceReal); From e506683e5ce0dfc3bfbd0f56fa33593030e88dad Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Sep 2026 19:26:18 +0530 Subject: [PATCH 3/5] fix(nextjs): preserve nested staged link targets Signed-off-by: Aman Varshney (cherry picked from commit 9c938713f80e0c71ca1a22bda78901ab7ea838f2) --- .../nextjs/src/__tests__/assemble.test.ts | 65 ++++++++++++++ .../2-authoring/nextjs/src/control/build.ts | 87 +++++++++++++------ 2 files changed, 126 insertions(+), 26 deletions(-) diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts index efad20d05..61aaead73 100644 --- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts @@ -214,6 +214,71 @@ describe('assemble()', () => { expect(result.watch).toContain(fs.realpathSync(source)); }, 20_000); + test('stages a traced sibling referenced by a relative link inside an absolute target', async () => { + const root = makeAppRoot(); + const { appRel } = writeNextBuild(root); + const standalone = path.join(root, '.next', 'standalone'); + const store = path.join(root, 'node_modules', '.pnpm', 'pkg@1.0.0', 'node_modules'); + const source = path.join(store, 'pkg'); + const sibling = path.join(store, 'helper'); + fs.mkdirSync(source, { recursive: true }); + fs.mkdirSync(sibling, { recursive: true }); + fs.writeFileSync(path.join(sibling, 'marker.txt'), 'traced sibling\n'); + fs.symlinkSync('../helper', path.join(source, 'helper'), 'dir'); + const linkDir = path.join(standalone, appRel, '.next', 'node_modules'); + fs.mkdirSync(linkDir, { recursive: true }); + fs.symlinkSync(source, path.join(linkDir, 'pkg-traced'), 'dir'); + + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-')); + tmpDirs.push(cwd); + const result = await assemble({ + address: 'storefront.web', + cwd, + build: nextjs({ module: moduleUrl(root), appDir: '..' }), + }); + + const bundle = path.join(cwd, '.prisma-composer', 'artifacts', 'storefront.web', 'bundle'); + const bundledLink = path.join(bundle, appRel, '.next', 'node_modules', 'pkg-traced'); + const bundledTarget = path.resolve(path.dirname(bundledLink), fs.readlinkSync(bundledLink)); + const nestedLink = path.join(bundledTarget, 'helper'); + const nestedTarget = path.resolve(path.dirname(nestedLink), fs.readlinkSync(nestedLink)); + expect(fs.lstatSync(nestedLink).isSymbolicLink()).toBe(true); + expect(nestedTarget.startsWith(`${bundle}${path.sep}`)).toBe(true); + expect(fs.readFileSync(path.join(nestedTarget, 'marker.txt'), 'utf8')).toContain( + 'traced sibling', + ); + expect(result.watch).toContain(fs.realpathSync(source)); + expect(result.watch).toContain(fs.realpathSync(sibling)); + }, 20_000); + + test('rejects an external nested link even when relocation would make it hit bundle content', async () => { + const root = makeAppRoot(); + const { appRel } = writeNextBuild(root); + const standalone = path.join(root, '.next', 'standalone'); + const source = path.join(root, 'pkg'); + fs.mkdirSync(source); + const outsideName = `${path.basename(root)}-outside`; + const outside = path.join(path.dirname(root), outsideName); + tmpDirs.push(outside); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'marker.txt'), 'outside trace\n'); + fs.symlinkSync(path.relative(source, outside), path.join(source, 'escaped'), 'dir'); + const collision = path.join(standalone, outsideName); + fs.mkdirSync(collision); + fs.writeFileSync(path.join(collision, 'marker.txt'), 'bundle collision\n'); + const linkDir = path.join(standalone, appRel, '.next', 'node_modules'); + fs.mkdirSync(linkDir, { recursive: true }); + fs.symlinkSync(source, path.join(linkDir, 'pkg-traced'), 'dir'); + + await expect( + assemble({ + address: 'storefront.web', + cwd: root, + build: nextjs({ module: moduleUrl(root), appDir: '..' }), + }), + ).rejects.toThrow(/symlink outside the declared tracing root/); + }, 20_000); + test('does not let an occupied bundle path shadow an absolute-link target', async () => { const root = makeAppRoot(); const { appRel } = writeNextBuild(root); diff --git a/packages/0-framework/2-authoring/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index 740bb11a3..5aaaebd2f 100644 --- a/packages/0-framework/2-authoring/nextjs/src/control/build.ts +++ b/packages/0-framework/2-authoring/nextjs/src/control/build.ts @@ -176,41 +176,76 @@ async function stageAbsoluteStandaloneLinkTargets( const stagedTargets = new Map(); let stagingRoot: string | undefined; let nextStagedTarget = 0; - let staged = true; - while (staged) { - staged = false; - for (const linkPath of await collectSymlinks(bundleDir)) { - const rawTarget = await fs.promises.readlink(linkPath); - if (!path.isAbsolute(rawTarget)) continue; - let sourceReal: string; + /** Copies one trusted target and repairs links whose meaning relocation would change. */ + async function stageSource(sourceReal: string): Promise { + const existing = stagedTargets.get(sourceReal); + if (existing !== undefined) return existing; + + stagingRoot ??= await createAbsoluteLinkStagingRoot(bundleDir); + const target = path.join(stagingRoot, String(nextStagedTarget)); + nextStagedTarget += 1; + stagedTargets.set(sourceReal, target); + + const sourceStat = await fs.promises.stat(sourceReal); + await fs.promises.cp(sourceReal, target, { recursive: true, verbatimSymlinks: true }); + stagedSources.add(sourceReal); + + if (!sourceStat.isDirectory()) return target; + for (const stagedLink of await collectSymlinks(target)) { + const sourceLink = path.join(sourceReal, path.relative(target, stagedLink)); + const rawTarget = await fs.promises.readlink(sourceLink); + const sourceTarget = path.isAbsolute(rawTarget) + ? rawTarget + : path.resolve(path.dirname(sourceLink), rawTarget); + if (!path.isAbsolute(rawTarget) && isWithin(sourceReal, sourceTarget)) continue; + + let nestedSourceReal: string; try { - sourceReal = await fs.promises.realpath(linkPath); + nestedSourceReal = await fs.promises.realpath(sourceLink); } catch { - continue; + throw new Error( + `cannot stage ${sourceReal}: it contains a dangling symlink (${sourceLink} -> ${rawTarget})`, + ); } - if (!isWithin(tracedRootReal, sourceReal)) continue; - - let target = stagedTargets.get(sourceReal); - if (target === undefined) { - stagingRoot ??= await createAbsoluteLinkStagingRoot(bundleDir); - target = path.join(stagingRoot, String(nextStagedTarget)); - nextStagedTarget += 1; - await fs.promises.cp(sourceReal, target, { recursive: true, verbatimSymlinks: true }); - stagedTargets.set(sourceReal, target); + if (!isWithin(tracedRootReal, nestedSourceReal)) { + throw new Error( + `cannot stage ${sourceReal}: it contains a symlink outside the declared tracing root (${sourceLink} -> ${rawTarget})`, + ); } - const sourceStat = await fs.promises.stat(sourceReal); - const relativeTarget = path.relative(path.dirname(linkPath), target); - await fs.promises.rm(linkPath, { recursive: true, force: true }); + const nestedTarget = await stageSource(nestedSourceReal); + const nestedStat = await fs.promises.stat(nestedSourceReal); + await fs.promises.rm(stagedLink, { recursive: true, force: true }); await fs.promises.symlink( - relativeTarget, - linkPath, - sourceStat.isDirectory() ? 'dir' : 'file', + path.relative(path.dirname(stagedLink), nestedTarget), + stagedLink, + nestedStat.isDirectory() ? 'dir' : 'file', ); - stagedSources.add(sourceReal); - staged = true; } + return target; + } + + for (const linkPath of await collectSymlinks(bundleDir)) { + const rawTarget = await fs.promises.readlink(linkPath); + if (!path.isAbsolute(rawTarget)) continue; + + let sourceReal: string; + try { + sourceReal = await fs.promises.realpath(linkPath); + } catch { + continue; + } + if (!isWithin(tracedRootReal, sourceReal)) continue; + + const target = await stageSource(sourceReal); + const sourceStat = await fs.promises.stat(sourceReal); + await fs.promises.rm(linkPath, { recursive: true, force: true }); + await fs.promises.symlink( + path.relative(path.dirname(linkPath), target), + linkPath, + sourceStat.isDirectory() ? 'dir' : 'file', + ); } return [...stagedSources]; } From b90d5384014b0d13d15a6133a8bbadb628c81a40 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 14 Sep 2026 15:04:44 +0530 Subject: [PATCH 4/5] fix(nextjs): normalize Windows watcher test paths and document staging Signed-off-by: Aman Varshney (cherry picked from commit d5bba3e608cbb2aa5a4ae83c68b10c71298fe259) --- docs/guides/deploying.md | 6 ++++++ .../2-authoring/nextjs/src/__tests__/assemble.test.ts | 6 +++--- skills/prisma-composer-core-concepts/SKILL.md | 4 ++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 76d920f87..f0d472a50 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -34,6 +34,12 @@ for hoisted installations. On Windows it prefers `alchemy.exe`, then `alchemy.cmd`, then the extensionless shim; POSIX uses `alchemy`. An installed Windows shim must not be reported as a missing Alchemy dependency. +Next.js standalone builds on Windows may contain absolute package symlinks. +Composer stages their targets only when they are inside Next's declared +`outputFileTracingRoot`, then rewrites the links to relative paths inside the +artifact. Nested links must satisfy the same boundary. Dangling or external +targets still fail assembly; do not flatten or dereference the standalone tree. + `prisma-composer deploy` does not build for you — it assembles what your build produced: diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts index 61aaead73..a201233fa 100644 --- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts @@ -211,7 +211,7 @@ describe('assemble()', () => { expect(fs.lstatSync(bundledLink).isSymbolicLink()).toBe(true); expect(bundledTarget.startsWith(`${bundle}${path.sep}`)).toBe(true); expect(fs.readFileSync(path.join(bundledTarget, 'index.js'), 'utf8')).toContain('pg'); - expect(result.watch).toContain(fs.realpathSync(source)); + expect(result.watch).toContain(await fs.promises.realpath(source)); }, 20_000); test('stages a traced sibling referenced by a relative link inside an absolute target', async () => { @@ -247,8 +247,8 @@ describe('assemble()', () => { expect(fs.readFileSync(path.join(nestedTarget, 'marker.txt'), 'utf8')).toContain( 'traced sibling', ); - expect(result.watch).toContain(fs.realpathSync(source)); - expect(result.watch).toContain(fs.realpathSync(sibling)); + expect(result.watch).toContain(await fs.promises.realpath(source)); + expect(result.watch).toContain(await fs.promises.realpath(sibling)); }, 20_000); test('rejects an external nested link even when relocation would make it hit bundle content', async () => { diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 634a111cc..a4c0b5e74 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -216,6 +216,10 @@ deploy. Rules that bite: calls `load()` needs `export const dynamic = 'force-dynamic'`, because the runtime environment doesn't exist at build time and Next ignores runtime env for prerendered routes. + On Windows, absolute package links are staged from the declared + `outputFileTracingRoot` and rewritten as relative in-artifact links. + Targets outside that root, including nested links, and unresolved dangling + targets remain errors. Do not dereference the standalone tree. 4. **Always build before `deploy` or `dev`.** Neither builds for you. Deploy configuration lives in `prisma-composer.config.ts` (or `.mts`, `.mjs`, From c48ba8316b3921277192498eaf9d08bb99fa95b8 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 23 Sep 2026 03:31:29 +0530 Subject: [PATCH 5/5] fix(nextjs): stage absolute links through the Windows bundle helper Signed-off-by: Aman Varshney --- .../nextjs/src/__tests__/assemble.test.ts | 13 ++++++++++++- .../2-authoring/nextjs/src/control/build.ts | 15 ++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts index a201233fa..82ef155f8 100644 --- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { isWithin } from '@internal/bundle-paths'; import { assemble, standaloneServerPath } from '../exports/control.ts'; import nextjs from '../exports/index.ts'; @@ -421,7 +422,7 @@ describe('assemble()', () => { 'links the bundle with junctions on Windows, never a directory symlink', async () => { const root = makeAppRoot(); - writeNextBuild(root); + const { appRel } = writeNextBuild(root); const standalone = path.join(root, '.next', 'standalone'); const storeRelative = path.join('node_modules', '.pnpm', 'semver@6.3.1', 'node_modules'); const source = path.join(root, storeRelative, 'semver'); @@ -434,6 +435,12 @@ describe('assemble()', () => { path.join(linkDir, 'semver'), 'junction', ); + const absoluteSource = path.join(root, 'node_modules', 'pg'); + fs.mkdirSync(absoluteSource, { recursive: true }); + fs.writeFileSync(path.join(absoluteSource, 'index.js'), 'module.exports = "pg";\n'); + const absoluteLinkDir = path.join(standalone, appRel, '.next', 'node_modules'); + fs.mkdirSync(absoluteLinkDir, { recursive: true }); + fs.symlinkSync(absoluteSource, path.join(absoluteLinkDir, 'pg-traced'), 'junction'); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-')); tmpDirs.push(cwd); @@ -455,6 +462,10 @@ describe('assemble()', () => { expect(linkTarget(path.join(bundle, 'node_modules', 'next-linked'))).toBe('next'); expect(linkTarget(staged)).toBe('../semver@6.3.1/node_modules/semver'); expect(fs.readFileSync(path.join(staged, 'index.js'), 'utf8')).toContain('6.3.1'); + const absoluteLink = path.join(bundle, appRel, '.next', 'node_modules', 'pg-traced'); + const stagedAbsoluteTarget = fs.realpathSync(absoluteLink); + expect(isWithin(fs.realpathSync(bundle), stagedAbsoluteTarget)).toBe(true); + expect(fs.readFileSync(path.join(absoluteLink, 'index.js'), 'utf8')).toContain('pg'); }, 20_000, ); diff --git a/packages/0-framework/2-authoring/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index 5aaaebd2f..7da04f0fe 100644 --- a/packages/0-framework/2-authoring/nextjs/src/control/build.ts +++ b/packages/0-framework/2-authoring/nextjs/src/control/build.ts @@ -25,13 +25,13 @@ * Paths are file-relative (ADR-0004): `appDir` resolves against * `dirname(build.module)`. */ - import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { assertBundleSymlinksStayInside, copyTreeVerbatim, + createBundleLink, isWithin, repairWindowsDirectorySymlinks, } from '@internal/bundle-paths'; @@ -188,7 +188,7 @@ async function stageAbsoluteStandaloneLinkTargets( stagedTargets.set(sourceReal, target); const sourceStat = await fs.promises.stat(sourceReal); - await fs.promises.cp(sourceReal, target, { recursive: true, verbatimSymlinks: true }); + await copyTreeVerbatim(sourceReal, target); stagedSources.add(sourceReal); if (!sourceStat.isDirectory()) return target; @@ -198,7 +198,7 @@ async function stageAbsoluteStandaloneLinkTargets( const sourceTarget = path.isAbsolute(rawTarget) ? rawTarget : path.resolve(path.dirname(sourceLink), rawTarget); - if (!path.isAbsolute(rawTarget) && isWithin(sourceReal, sourceTarget)) continue; + if (isWithin(sourceReal, sourceTarget)) continue; let nestedSourceReal: string; try { @@ -216,8 +216,8 @@ async function stageAbsoluteStandaloneLinkTargets( const nestedTarget = await stageSource(nestedSourceReal); const nestedStat = await fs.promises.stat(nestedSourceReal); - await fs.promises.rm(stagedLink, { recursive: true, force: true }); - await fs.promises.symlink( + await fs.promises.unlink(stagedLink); + await createBundleLink( path.relative(path.dirname(stagedLink), nestedTarget), stagedLink, nestedStat.isDirectory() ? 'dir' : 'file', @@ -236,12 +236,13 @@ async function stageAbsoluteStandaloneLinkTargets( } catch { continue; } + if (isWithin(bundleDir, sourceReal)) continue; if (!isWithin(tracedRootReal, sourceReal)) continue; const target = await stageSource(sourceReal); const sourceStat = await fs.promises.stat(sourceReal); - await fs.promises.rm(linkPath, { recursive: true, force: true }); - await fs.promises.symlink( + await fs.promises.unlink(linkPath); + await createBundleLink( path.relative(path.dirname(linkPath), target), linkPath, sourceStat.isDirectory() ? 'dir' : 'file',