Skip to content

feat(node): node() can deploy a built directory, not just a single file#116

Merged
wmadden merged 3 commits into
mainfrom
claude/node-dir-assemble
Jul 17, 2026
Merged

feat(node): node() can deploy a built directory, not just a single file#116
wmadden merged 3 commits into
mainfrom
claude/node-dir-assemble

Conversation

@wmadden-electric

@wmadden-electric wmadden-electric commented Jul 17, 2026

Copy link
Copy Markdown
Contributor
// Before — the only shape node() could deploy: one self-contained file.
build: node({ module: import.meta.url, entry: '../dist/server.mjs' })

// After — a directory you built, naming the file inside it that boots.
build: node({ module: import.meta.url, dir: '../dist/server', entry: 'start.js' })

node() now accepts a built directory, not just a single file. Pass dir
and you get the new form; leave it off and you get exactly what you have today.

node() is the build adapter you put on a service to tell the deploy where your
built server is. It has always accepted one shape — a single self-contained
file — and this adds a second.

Why we need it

Plenty of builds don't produce one file. open-chat's doesn't: pointing bun build at an HTML entry emits a server plus the client bundle, the CSS and the
images beside it, and the server serves those siblings over HTTP at runtime.
Ship only the server and it boots fine, then 404s everything it exists to serve.
There was no way to say "all of this ships" — so open-chat's deploy was stuck.

We already copy whole directories for Next.js: nextjs() ships Next's
standalone output. But it does that by knowing Next — reading Next's build
manifest to find the app. That knowledge is Next-specific and doesn't generalise
to a plain Bun or Node server, so a directory-shaped build had no adapter at all.

How it works

  • dir resolves relative to your service module — the same rule entry
    already follows, so both read like import specifiers.
  • entry then resolves inside dir, and may be nested (server/start.js).
  • Deploy copies that tree into the artifact verbatim and points the generated
    boot wrapper at the file you named.

One runtime consequence worth knowing, because it's easy to hit blind: the tree
lands wherever the platform unpacks it, so your server must locate its siblings
relative to itself (import.meta.url), not the working directory.

Nothing is discovered. We don't walk the tree hunting for an entry, don't guess
from filenames, don't read a manifest, and don't build anything — you name the
directory and the file, and precisely that ships. That's the framework's standing
rule (ADR-0005):
you build, we assemble.

What it refuses

"We copy exactly what you named" is the whole contract, so anything that would
quietly bend it fails early, naming the offending path:

  • dir is missing, or isn't a directory.
  • entry isn't inside dir.
  • entry points outside dir (../…, or an absolute path). Only dir gets
    copied, so this is a mistake to report, not a way to reach a file elsewhere.
  • dir overlaps the deploy's working directory, either way round. Inside it,
    that directory is cleared on every run — we'd delete the copy source before
    copying it. Around it, the copy would recurse into its own output.
  • The tree contains a symlink (see Alternatives below).

What doesn't change

The single-file form: same type, same code path, same bytes on disk. Every
existing example keeps working untouched. Two things hold that down — a test
pinning that entry alone copies exactly one file no matter how many siblings
sit beside it in the build directory, and a real deploy of an untouched
single-file example (below).

Evidence

The new form, deployed to real Prisma Cloud (examples/env-param, since
torn down):

GET /          -> {"greeting":"directory-form-proof-hello"}
GET /logo.svg  -> http 200, 297 bytes, content-type: image/svg+xml

logo.svg is the entry's sibling. It can only exist in the artifact if the
whole tree was copied, so serving it is the proof that the tree arrived rather
than just the entry — checked against the live URL independently of the smoke
script, and byte-for-byte against what the build emitted.

The old form, still green: examples/storage — untouched, still
single-file — deployed clean and served over HTTP, and its artifact contained
exactly one file.

Unit tests cover every rule above. I checked they actually fail when the
behaviour breaks rather than trusting a green run: breaking the tree copy fails
two of them, and making the invalid dir-without-entry call valid makes tsc
report the @ts-expect-error as unused. Repo-wide lint, typecheck, all 48 test
tasks and lint:deps pass.

Docs

The guide and the agent skill both document the new form, since a user-visible
change has to land in both in the same PR. Both carry the two rules that
actually bite (no symlinks; entry lives inside dir) and the
import.meta.url consequence above.

Alternatives considered

A second adapter instead of extending node(). Rejected (operator decision,
2026-07-17). The shape is still "a plain server process the platform boots" —
only the number of files differs — so a parallel adapter would duplicate
node()'s whole contract to express one extra path.

Inferring the tree from the entry — copy the entry's siblings, or walk for
the entry. Rejected: it guesses. nextjs() gets away with copying a tree only
because it reads Next's own manifest rather than guessing; there is no
equivalent to read for an arbitrary build, and the author already knows the
answer.

Dereferencing symlinks on copy, rather than failing. Rejected on two counts:
it would silently rewrite the tree your build produced, and a link pointing
outside dir would drag in files you never named. The platform's packager
rejects symlinks regardless, so the choice was only ever where it fails —
here, naming the link, or later and further from the cause.

Where the deployed proof lives. examples/env-param hosts it because every
other example using node() is unsuitable: an RPC service with no browsable
HTTP surface (cron, catalog, orders, auth, blobs), a Next.js app on the other
adapter (both storefronts), or a database round trip (pn-widgets). env-param is
the only single-service, no-database, plain-HTTP example, and it already had a
harness that resolves the deployed URL and polls it. Its own purpose survives
intact — the greeting still comes from a platform variable.

Out of scope: nextjs() (untouched), bundling or transforming app code, and
open-chat's own build.

open-chat's built runnable is a directory — a Bun server plus the client
bundle, CSS and images its HTML import emits as siblings for it to serve.
node() could only assemble a single self-contained file, so that shape had
no adapter. Rather than add a second adapter, node() gains a directory form:

    node({ module, dir: "../dist/server", entry: "start.js" })

dir resolves file-relative to dirname(module) (ADR-0004, the rule entry
already uses); entry then resolves inside dir and may be nested. assemble()
copies the tree into bundle/ and points the boot wrapper at bundle/<entry>,
mirroring what nextjs() already does for Next's standalone output. Nothing
is inferred (ADR-0005): the author names the directory and the entry, and we
copy exactly that — no tree-walking, no filename heuristics, no build step.

Without dir it is today's single-file form, untouched; a test now pins that
entry alone copies exactly one file, however much else sits beside it.

Validation is loud and early: a missing or non-directory dir, an entry
missing inside it, an entry escaping dir via ../ or an absolute path, and
dir overlapping the deploy working dir in either direction (inside it, the
rm would delete the copy source; around it, the copy would recurse into its
own output).

Symlinks in dir fail, naming them, rather than being dereferenced on copy:
Compute's packager rejects symlinks, the artifact must be the tree the
author's build produced, and following a link could pull in files from
outside dir that the author never named. Failing here beats failing in the
packager, far from the cause.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ng asset

The directory form needs a deployed proof: a service whose built runnable
is a directory — an entry plus a static asset it serves over HTTP — where
fetching the asset from the deployed URL shows the whole tree arrived, not
just the entry.

env-param hosts it. Every other node() example is either an RPC service with
no browsable HTTP surface (cron, catalog, orders, auth, blobs), a Next.js app
on the nextjs() adapter (both storefronts), or a database round trip
(pn-widgets). env-param is the only single-service, no-database, plain-HTTP
example, and it already has the smoke harness this needs — it resolves the
deployed URL via the Management API and polls.

The build now emits dist/server/ via --outdir and copies assets/ in beside
it; the server serves assets/logo.svg, resolved against import.meta.url so it
works from wherever the artifact is unpacked. The smoke script fetches the
asset once the service is up and asserts its marker.

Its env-param story is untouched: the greeting still comes from a platform var
through config(). Single-file deploy coverage stays where CI automates it —
storefront-auth and pn-widgets, the two examples e2e-deploy actually deploys.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
A user-facing surface change lands in both surfaces in the same PR: the guide
is the website content, the skill is installed into user repos.

Both describe the form the same way — name the directory and the entry,
nothing is inferred — and carry the two rules that actually bite: no symlinks
in the tree (the packager rejects them), and entry must be a file inside dir.
Both note the runtime consequence users would otherwise hit blind: resolve
siblings against import.meta.url, not the working directory.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@116
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@116

commit: 1efc547

@wmadden-electric wmadden-electric changed the title feat(node): node() assembles a directory with a named entry feat(node): node() can deploy a built directory, not just a single file Jul 17, 2026
@wmadden
wmadden merged commit 668c8b0 into main Jul 17, 2026
15 checks passed
@wmadden
wmadden deleted the claude/node-dir-assemble branch July 17, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants