Skip to content
Closed
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -186,6 +187,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 () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor · tests — No test for a dangling absolute link being left for the validator to reject

The PR description promises "Leave dangling and out-of-root links as errors" for absolute links, and the code implements both paths (realpath failure → continue at build.ts:235-238, then the dangling-symlink rejection from assertBundleSymlinksStayInside). Only the out-of-root case is tested (this test). A dangling absolute link exercises a distinct skip-and-fail path with a different error message and is easy to regress (e.g., a future change that treats realpath failure as staging failure or silently drops the link).

Recommended fix

Add a sibling test that creates the absolute link with a nonexistent target (e.g. fs.symlinkSync(path.join(root, 'node_modules', 'gone'), linkPath, 'dir')) and asserts assemble rejects with /dangling symlink/.

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 Expand Up @@ -278,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');
Expand All @@ -291,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);
Expand All @@ -312,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,
);
Expand Down
117 changes: 115 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 @@ -31,6 +31,7 @@ import { fileURLToPath } from 'node:url';
import {
assertBundleSymlinksStayInside,
copyTreeVerbatim,
createBundleLink,
isWithin,
repairWindowsDirectorySymlinks,
} from '@internal/bundle-paths';
Expand Down Expand Up @@ -139,6 +140,117 @@ 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 copyTreeVerbatim(sourceReal, target);
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 (isWithin(sourceReal, sourceTarget)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor · correctness — Nested absolute symlink inside a staged target is left pointing at the build machine on POSIX, failing assembly

On POSIX copyTreeVerbatim copies symlinks verbatim, so a nested symlink whose raw target is absolute and lexically inside its own source tree (isWithin(sourceReal, sourceTarget)) hits the continue and is never rewritten. The staged copy's link still stores the build-machine absolute path, so assertBundleSymlinksStayInside later rejects the whole assembly with "symlink whose target escapes the bundle" — even though the target is inside the declared tracing root and stageable. The same case works on Windows only because copyTreeThroughBundleLinks re-anchors in-source junctions into the staged copy before this check runs. Fails safe (no bad artifact), but turns an assemblable build into an error.

Recommended fix

Skip the rewrite only when the link inside the staged copy already resolves within that copy, instead of when the original's raw target is lexically within sourceReal:

Suggested change
if (isWithin(sourceReal, sourceTarget)) continue;
const stagedRaw = await fs.promises.readlink(stagedLink);
if (isWithin(target, path.resolve(path.dirname(stagedLink), stagedRaw))) continue;

This keeps the Windows re-anchored-junction skip (the staged junction resolves inside the staged copy) while rewriting POSIX verbatim absolute links through the existing realpath → boundary check → stage path.


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.unlink(stagedLink);
await createBundleLink(
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(bundleDir, sourceReal)) continue;
if (!isWithin(tracedRootReal, sourceReal)) continue;

const target = await stageSource(sourceReal);
const sourceStat = await fs.promises.stat(sourceReal);
await fs.promises.unlink(linkPath);
await createBundleLink(
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 +348,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 +390,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 @@ -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`,
Expand Down
Loading