Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# ADR-0005: Users build their app; the framework assembles deploy artifacts from built output

Superseded in part by [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md): assembly may trace the declared entry's runtime files and preserve symlinks whose targets remain inside the final bundle; it still never dereferences a link or guesses an entry.

## Decision

The framework never initiates or configures a user's build. The contract is:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# ADR-0047: Compute assembly preserves safe runtime topology and proves routing

## Decision

Composer's Compute path turns declared built output into a self-contained artifact without rewriting application code:

```text
declared entry ──trace imports──▶ staged runtime files
safe symlink ────────────────▶ archived as a symlink
escaping link ────────────────▶ hard error
promoted URL ──route probe───▶ deploy succeeds
```

The Node directory adapter traces the explicitly declared entry's static runtime file graph and stages those files beside the directory the author named. It does not choose an entry, run a build, or bundle the application.

The Compute archive preserves a symlink as a tar symlink only after resolving its real target and proving that target remains inside the assembled bundle. It never dereferences the link. Dangling links and links that escape the bundle are errors.

The generated bootstrap may install a narrowly scoped compatibility shim when Compute's JavaScript runtime differs from the Node behavior a framework relies on. Such a shim must be feature-gated to that runtime and must run before the application entry is imported.

Compute also supplies `HOST=0.0.0.0` when the author did not configure a host. Framework servers must listen on Compute's network interface rather than a loopback-only default; an explicit author value remains authoritative.

After promotion, lowering probes the stable application URL. Any application-owned HTTP response proves that routing reached the deployment; the platform's explicit missing-service response does not. A deployment is not reported successful while that marker remains.

## Reasoning

"Users build; Composer assembles" is a boundary between owning a build and manufacturing a deployment artifact. It does not require Composer to ignore the runtime topology recorded by a build. Astro's Node adapter, for example, emits server files that deliberately retain bare package imports. Copying only `dist/` preserves the bytes but not the runnable program. Tracing from the author-declared entry follows package metadata and import edges deterministically; it is file assembly, not a second application build.

The same distinction applies to symlinks. Dereferencing a package-manager link can silently pull arbitrary deploy-machine files into an artifact, which remains forbidden. Preserving the link itself retains the build's topology. Resolving the target only for validation proves that the archived link cannot escape the artifact, including through a chain of links, without copying the target through the link.

Frameworks also exercise details of the Compute runtime that a plain HTTP server may not. A compatibility shim belongs in Composer's generated bootstrap because it is part of the hosting envelope, not the user's framework build. The shim is deliberately narrow: the current URL custom-inspect setter restores Node-compatible assignment semantics for Bun without patching SvelteKit output or changing unrelated globals.

Finally, an API status of `running` and a successful promotion describe control-plane progress, not data-plane routing. The stable URL is the observable contract returned to the author. Probing it closes that gap while treating the application's own status code as application policy rather than deployment policy.

## Consequences

- Node directory artifacts can carry runtime packages that a framework intentionally leaves external.
- Safe package-manager and framework symlinks remain links in both cloud and local artifacts; escaping or dangling links fail before upload.
- Runtime compatibility code is isolated in the generated bootstrap and covered by framework deployment tests.
- Framework servers receive a listen-all host default without overriding an author-configured host.
- A deploy can take longer after promotion, and fails instead of returning a URL that still routes to the platform's missing-service handler.
- These mechanisms are compatibility ownership, not permanent duplication. When the upstream Alchemy Compute provider supplies an equivalent archive, build staging, runtime bootstrap, or readiness guarantee, Composer deletes the corresponding local mechanism rather than keeping two implementations.

## Alternatives considered

**Require every build directory to be flat and fully self-contained.** Rejected: standard framework outputs do not all have that shape, and forcing every application to maintain post-build copy scripts moves hosting assembly into userland.

**Dereference symlinks during packaging.** Rejected: it changes the build topology and can package files outside the declared artifact boundary.

**Bundle the application entry again.** Rejected: that crosses the user-build boundary and creates a second framework compatibility surface. Static file tracing preserves the application's emitted code.

**Treat promotion as deployment readiness.** Rejected: promotion can succeed while the stable endpoint still returns the platform's missing-service response.

## Related

- [ADR-0005](ADR-0005-users-build-the-framework-assembles.md) — users own builds; Composer owns deterministic artifact assembly.
- [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — Alchemy is the provisioning engine behind deploy.
- [Architectural principles](../01-principles/architectural-principles.md) — Composer does not bundle application code or guess build output.
1 change: 1 addition & 0 deletions docs/design/90-decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl
- [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md) — Errors are structural envelopes with dotted `NAMESPACE.SUBCODE` codes (the shared prisma/prisma foundation, duplicated pending extraction): structured at origin with why/fix splits, no catch-all codes, bugs carry no code (exit 1 + report hint), recognition is structural (`CliStructuredError.is()`), operation results ride the shared `Result` `ok` discriminator, expected failures exit 2 — with the alchemy child-status passthrough as the documented exception.
- [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) — Deploy state lives behind the platform state API (the Management API implements Alchemy's stock `HttpStateApi` wire contract per Branch; composer's state layer is Alchemy's stock HTTP client), and deploys hold a server-side per-`(stack, stage)` lease (TTL 60s, heartbeated, released on exit; contention fails fast naming the holder; state operations without a live lease fail 409). Supersedes ADR-0010 (lock → lease) and the storage half of ADR-0034 (Branch scoping and lifetime stand; the visible per-stage database is gone); closes ADR-0012 as obsolete. No migration: legacy stages are refused until destroyed or deleted.
- [ADR-0046](ADR-0046-the-orm-facade-is-a-peer-dependency.md) — `@prisma/composer-prisma-cloud` takes the Prisma Next postgres facade (`@prisma/orm-postgres`) as a **peer** dependency at one exact version, not a regular dependency: Composer registers an extension pack against the application's copy of the target, and two copies of a shell in one tree means two codec/operation registries and two class identities — a value from one is rejected by the other, silently. As a peer, that combination fails at install instead. Every `@prisma/orm-*` spec in the workspace is one exact version and all name the same one (`scripts/lint-orm-pins.mjs`). `@prisma/orm-toolchain`, which Composer drives rather than extends, stays a regular dependency. Replaces ADR-0022's consequence bullet on how the ORM is installed.
- [ADR-0047](ADR-0047-compute-assembly-preserves-safe-runtime-topology.md) — Compute assembly traces runtime files from the author-declared Node entry without rebundling app code, preserves only symlinks whose resolved targets remain inside the staged bundle, installs narrowly runtime-gated bootstrap compatibility when a framework needs Node semantics, and reports success only after the promoted stable URL no longer returns the platform's missing-service marker. Each local mechanism is removed once the upstream Alchemy Compute provider owns the equivalent guarantee. Supersedes ADR-0005's blanket ban on symlinks and its assumption that directory output is already self-contained.
Comment thread
AmanVarshney01 marked this conversation as resolved.
Outdated
12 changes: 6 additions & 6 deletions packages/0-framework/2-authoring/nextjs/src/control/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@
* `outputFileTracingRoot` is the monorepo root) is *read from Next's own build
* manifest* (`.next/required-server-files.json`'s `relativeAppDir`), never walked
* for or computed from a hardcoded depth. It does not launder: node_modules is
* shipped exactly as `next build` produced it, so a symlinked (non-hoisted)
* node_modules is the packager's hard error — the same misconfiguration crashes
* the standalone server at boot, so it must be a flat install (npm, or pnpm/bun
* with a hoisted node-linker).
* shipped exactly as `next build` produced it. The packager preserves a link
* only after resolving its target inside the assembled bundle, and rejects
* dangling or escaping links.
*
* Artifact layout: `<workDir>/main.mjs` (our wrapper) + `<workDir>/bundle/`
* (the standalone tree, with static/public copied in). The packager adds
Expand Down Expand Up @@ -108,8 +107,9 @@ export async function assemble(input: AssembleInput): Promise<Bundle> {
await fs.promises.mkdir(workDir, { recursive: true });
const bundleDir = path.join(workDir, 'bundle');

// Ship the standalone tree as `next build` produced it (a symlinked
// node_modules stays symlinked → the packager rejects it, correctly).
// Ship the standalone tree as `next build` produced it. Framework-emitted
// links stay links; the packager validates that every target remains inside
// the assembled bundle before emitting it into the archive.
await fs.promises.cp(standaloneRoot, bundleDir, { recursive: true });

// The documented copy: Next omits the client assets from standalone; place
Expand Down
1 change: 1 addition & 0 deletions packages/0-framework/2-authoring/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"dependencies": {
"@internal/core": "workspace:0.6.0",
"@vercel/nft": "^1.10.2",
"esbuild": "^0.28.1"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -514,11 +514,7 @@ describe('assemble() — the directory form', () => {
).rejects.toThrow(/sits inside the build adapter's dir .* copy the artifact into itself/s);
});

test('rejects a tree containing a symlink, naming it — the packager rejects symlinks, and we ship what the build produced', async () => {
// Decided over dereferencing on copy: the artifact must be the tree the
// author's build produced (ADR-0005), and following a link could pull in
// files from outside dir that the author never named. Failing here beats
// failing in the packager, which reports it far from the cause.
test('rejects a tree symlink whose target is outside the final assembled bundle', async () => {
const serviceDir = makeServiceDir();
writeTree(path.join(serviceDir, 'dist'), {
'server/start.js': 'export default "app-entry";\n',
Expand All @@ -536,10 +532,10 @@ describe('assemble() — the directory form', () => {
address: 'svc',
cwd: makeCwd(),
}),
).rejects.toThrow(/contains symlinks.*server\/util\.js/s);
).rejects.toThrow(/symlink whose target escapes the bundle.*bundle\/util\.js/s);
});

test('reports a symlinked directory without descending into it', async () => {
test('rejects an escaping directory symlink without descending into it', async () => {
const serviceDir = makeServiceDir();
writeTree(path.join(serviceDir, 'dist'), {
'server/start.js': 'export default "app-entry";\n',
Expand All @@ -557,7 +553,27 @@ describe('assemble() — the directory form', () => {
address: 'svc',
cwd: makeCwd(),
}),
).rejects.toThrow(/contains symlinks.*server\/vendor/s);
).rejects.toThrow(/symlink whose target escapes the bundle.*bundle\/vendor/s);
});

test('preserves a relative directory symlink whose target stays inside the built tree', async () => {
const serviceDir = makeServiceDir();
writeTree(path.join(serviceDir, 'dist', 'server'), {
'start.js': 'export default "app-entry";\n',
'node_modules/real/index.js': 'export const value = 1;\n',
});
fs.symlinkSync('real', path.join(serviceDir, 'dist', 'server', 'node_modules', 'linked'));
writeServiceModule(serviceDir);

const result = await assemble({
build: node({ module: moduleUrl(serviceDir), dir: '../dist/server', entry: 'start.js' }),
address: 'svc',
cwd: makeCwd(),
});

const copied = path.join(result.dir, 'bundle', 'node_modules', 'linked');
expect(fs.lstatSync(copied).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(copied)).toBe('real');
});

test('rejects a dir that is itself a symlink to a directory — hard-errors instead of dereferencing it and copying the target', async () => {
Expand All @@ -579,7 +595,7 @@ describe('assemble() — the directory form', () => {
address: 'svc',
cwd: makeCwd(),
}),
).rejects.toThrow(/contains symlinks.*dist\/server/s);
).rejects.toThrow(/dir .* is itself a symlink/s);
});

test('rejects a dir that is itself a symlink to a FILE — the same hard error, not "not a directory"', async () => {
Expand All @@ -602,6 +618,34 @@ describe('assemble() — the directory form', () => {
address: 'svc',
cwd: makeCwd(),
}),
).rejects.toThrow(/contains symlinks.*dist\/server/s);
).rejects.toThrow(/dir .* is itself a symlink/s);
});

test('stages bare runtime dependencies traced from a directory entry (Astro Node output shape)', async () => {
const serviceDir = makeServiceDir();
const cwd = makeCwd();
writeTree(path.join(serviceDir, 'dist'), {
'server/entry.mjs': 'import { marker } from "runtime-fixture"; export default marker;\n',
});
const marker = installFixturePackage(serviceDir, 'runtime-fixture');
writeServiceModule(serviceDir);

const result = await assemble({
build: node({
module: moduleUrl(serviceDir),
dir: '../dist',
entry: 'server/entry.mjs',
}),
address: 'astro',
cwd,
});

expect(result.entry).toBe('bundle/server/entry.mjs');
expect(
fs.readFileSync(
path.join(result.dir, 'bundle', 'node_modules', 'runtime-fixture', 'index.js'),
'utf8',
),
).toContain(marker);
}, 20_000);
});
Loading
Loading