Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ac9d533
fix(nextjs): preserve absolute Windows standalone links safely
AmanVarshney01 Sep 2, 2026
eab26b4
fix(nextjs): isolate staged absolute link targets
AmanVarshney01 Sep 2, 2026
9c93871
fix(nextjs): preserve nested staged link targets
AmanVarshney01 Sep 2, 2026
a5617d7
Merge remote-tracking branch 'origin/main' into codex/fix-windows-nex…
AmanVarshney01 Sep 10, 2026
a1bbdcc
test(nextjs): verify current published CLI assembly on Windows
AmanVarshney01 Sep 10, 2026
01803db
test(nextjs): remove completed Windows diagnostic
AmanVarshney01 Sep 10, 2026
a4b385b
Merge remote-tracking branch 'origin/main' into codex/fix-windows-nex…
AmanVarshney01 Sep 14, 2026
d5bba3e
fix(nextjs): normalize Windows watcher test paths and document staging
AmanVarshney01 Sep 14, 2026
781e938
test(nextjs): verify published scaffold deploys from Windows
AmanVarshney01 Sep 14, 2026
c161ac0
test(nextjs): verify rendered seed data and clean deployment versions
AmanVarshney01 Sep 14, 2026
921be91
test(nextjs): align Composer provider versions in Windows diagnostic
AmanVarshney01 Sep 14, 2026
4737bc5
test(nextjs): resolve the temporary app path on the runner
AmanVarshney01 Sep 14, 2026
cc2be6e
test: remove temporary Windows deployment diagnostic from the PR
AmanVarshney01 Sep 14, 2026
00f2057
test: verify the npm minimum and real Next assembly on Windows
AmanVarshney01 Sep 14, 2026
4f5c68d
test: remove the one-off npm and assembly diagnostic
AmanVarshney01 Sep 14, 2026
aa72074
Merge remote-tracking branch 'origin/main' into codex/fix-windows-nex…
AmanVarshney01 Sep 14, 2026
6550212
Merge remote-tracking branch 'origin/main' into codex/fix-windows-nex…
AmanVarshney01 Sep 14, 2026
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
6 changes: 6 additions & 0 deletions docs/guides/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
143 changes: 143 additions & 0 deletions packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,149 @@ 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 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(bundledTarget.startsWith(`${bundle}${path.sep}`)).toBe(true);
expect(fs.readFileSync(path.join(bundledTarget, 'index.js'), 'utf8')).toContain('pg');
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 () => {
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(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 () => {
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);
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);
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);
Expand Down
116 changes: 114 additions & 2 deletions packages/0-framework/2-authoring/nextjs/src/control/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -139,6 +140,116 @@ async function collectSymlinks(root: string): Promise<string[]> {
return links;
}

async function createAbsoluteLinkStagingRoot(bundleDir: string): Promise<string> {
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 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
* untouched for the bundle validator to reject.
*/
async function stageAbsoluteStandaloneLinkTargets(
bundleDir: string,
manifest: ServerFilesManifest,
): Promise<string[]> {
const tracingRoot = manifest.tracingRoot;
if (tracingRoot === undefined || (await lstatIfPresent(tracingRoot)) === undefined) return [];

const tracedRootReal = await fs.promises.realpath(tracingRoot);
const stagedSources = new Set<string>();
const stagedTargets = new Map<string, string>();
let stagingRoot: string | undefined;
let nextStagedTarget = 0;

/** Copies one trusted target and repairs links whose meaning relocation would change. */
async function stageSource(sourceReal: string): Promise<string> {
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 {
nestedSourceReal = await fs.promises.realpath(sourceLink);
} catch {
throw new Error(
`cannot stage ${sourceReal}: it contains a dangling symlink (${sourceLink} -> ${rawTarget})`,
);
}
if (!isWithin(tracedRootReal, nestedSourceReal)) {
throw new Error(
`cannot stage ${sourceReal}: it contains a symlink outside the declared tracing root (${sourceLink} -> ${rawTarget})`,
);
}

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(
path.relative(path.dirname(stagedLink), nestedTarget),
stagedLink,
nestedStat.isDirectory() ? 'dir' : 'file',
);
}
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];
}

/** In-bundle link targets that the standalone tree does not contain — the
* repairs staging has to make. */
async function missingLinkTargets(bundleDir: string): Promise<string[]> {
Expand Down Expand Up @@ -236,7 +347,8 @@ export async function assemble(input: AssembleInput): Promise<Bundle> {
// 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);

Expand Down Expand Up @@ -277,7 +389,7 @@ export async function assemble(input: AssembleInput): Promise<Bundle> {
return {
dir: workDir,
entry: path.posix.join('bundle', appRel.split(path.sep).join('/'), 'server.js'),
watch: [standaloneRoot, ...stagedLinkTargets],
watch: [standaloneRoot, ...stagedAbsoluteLinkTargets, ...stagedMissingLinkTargets],
};
}

Expand Down
4 changes: 4 additions & 0 deletions skills/prisma-composer-core-concepts/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,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`,
Expand Down
Loading