feat(node): node() can deploy a built directory, not just a single file#116
Merged
Conversation
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>
commit: |
wmadden
approved these changes
Jul 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
node()now accepts a built directory, not just a single file. Passdirand 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 yourbuilt 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 buildat an HTML entry emits a server plus the client bundle, the CSS and theimages 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'sstandalone 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
dirresolves relative to your service module — the same ruleentryalready follows, so both read like import specifiers.
entrythen resolves insidedir, and may be nested (server/start.js).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:
diris missing, or isn't a directory.entryisn't insidedir.entrypoints outsidedir(../…, or an absolute path). Onlydirgetscopied, so this is a mistake to report, not a way to reach a file elsewhere.
diroverlaps 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.
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
entryalone copies exactly one file no matter how many siblingssit 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, sincetorn down):
logo.svgis the entry's sibling. It can only exist in the artifact if thewhole 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, stillsingle-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-entrycall valid makestscreport the
@ts-expect-erroras unused. Repo-wide lint, typecheck, all 48 testtasks and
lint:depspass.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;
entrylives insidedir) and theimport.meta.urlconsequence 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 onlybecause 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
dirwould drag in files you never named. The platform's packagerrejects 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-paramhosts it because everyother example using
node()is unsuitable: an RPC service with no browsableHTTP 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, andopen-chat's own build.