fix: make @webjsdev/core a peer dependency of @webjsdev/server - #1450
Conversation
Three of core's server-facing features are provider seams held in module
scope: asset(), cspNonce(), and the bound-form identity resolver. The server
installs each at boot by importing core and calling a setter, which only
reaches the app when both sides load the SAME module instance. Two copies of
core on disk are two independent sets of that state, so the setter lands on
one and the app reads the other.
Nothing throws. asset() returns bare paths and loses its immutable caching,
cspNonce() returns empty so an inline script is blocked under a CSP, and
formActionId answers null so a bound <form action=${fn}> loses the identity
the dispatcher reads. That silence is the reason this was found from the far
end: asset-helper-serve failed on a checkout carrying a stale nested copy
under packages/server/node_modules, and the only symptom it could report was
a url with no ?v=.
A regular dependencies entry is what lets npm nest that second copy whenever
it cannot dedupe to one. A peer is resolved against the app's own copy, and a
real version conflict is reported at install time rather than silently
satisfied by duplication. The regenerated lockfile shows the effect: nothing
but ws is nested under packages/server now.
The lockfile also picks up core 0.7.52, which the release commit bumped
without the lock following.
vivek7405
left a comment
There was a problem hiding this comment.
Read the whole diff again end to end. Four files, and the mechanism holds up.
What I checked beyond the diff itself. A scaffolded app declares BOTH @webjsdev/core: latest and @webjsdev/server: latest (create.js:442-443), so the peer resolves against the app's own copy in the exact shape webjs create emits, and an app that depends only on server still gets core through npm's peer auto-install. The lock regenerated cleanly and nests only ws under packages/server, which is the observable proof the fix does what it claims. Nothing in AGENTS.md, framework-dev.md, or the server README describes the old dependency shape, so no doc went stale. And the release checklist's dependent-range grep matches a peerDependencies entry the same as a dependencies one, so range maintenance at bump time flows unchanged.
The trade this makes, stated so it is a decision rather than a surprise: the day core bumps past ^0.7.51 before server's range widens, an install that would previously have nested a second core silently now fails loudly with a conflict. Loud beats three features quietly doing nothing, and the lockstep release flow is what keeps the ranges in step anyway. The one escape hatch worth knowing about is --legacy-peer-deps, which skips peers entirely; a scaffolded app is safe there because it declares core itself, and an app that only declared server would fail at boot with a module-not-found, which is still loud.
The new test reads the right package.json and pins both halves, including the dev range tracking the peer range so workspace dev resolves what apps will. The doc paragraph describes what actually ships, with no trace of the boot warning that was tried and removed.
Nothing must-fix.
#1447 landed the linked-worktree install guard, which is plausibly the vector that created the stale nested @webjsdev/core this branch was chasing: an npm install run inside a linked worktree writing real packages into the shared node_modules. The two changes are complementary, one stopping the install that creates a second copy and this one stopping npm from being entitled to create it at all. built-ins.md auto-merged, both paragraphs intact and on different subjects.
The bug
packages/server/test/dev/asset-helper-serve.test.jsfailed on a clean checkout with an assertion that could only say the rendered url carried no?v=. That points nowhere near the cause, so here it is.Three of
@webjsdev/core's server-facing features are provider seams held in module scope:asset()asset-url.jsimmutablecachingcspNonce()csp-nonce.jsform-action.jsformActionIdanswersnull, so<form action=${fn}>loses the identity the dispatcher reads (invariant 12)@webjsdev/serverinstalls each at boot by importing core and calling a setter. That only reaches the app when both sides load the same module instance. Two copies of core on disk are two independent sets of module-scope state, so the setter lands on one and the app reads the other. Nothing throws in any of the three cases.The failing checkout had a stale
packages/server/node_modules/@webjsdev/core. The server resolved that copy; the app resolved the workspace one, whose_providerwas stillnull. Removing the nested copy makes the test pass 2/2, and the live website then rendershref="/public/tailwind.css?v=99a5f9074737"instead of the bare path.The fix
@webjsdev/coremoves fromdependenciestopeerDependencies(carried indevDependenciesat the same range for workspace dev). A regular dependency is exactly what lets npm nest a second copy whenever it cannot dedupe to one. A peer is resolved against the app's own copy, and a genuine version conflict is reported at install time rather than silently satisfied by duplication.The regenerated lockfile shows the effect:
packages/server/node_modulesnow nests onlyws.This is a real-app exposure, not just a test artifact. Any app whose core version does not dedupe against
@webjsdev/server's range could get the nested copy and lose all three features with no error.What I tried and removed
I first added a boot-time warning comparing the app's core path against the server's. It broke
dev-handler.test.js, which asserts boot emits no warnings because analysis is lazy, and it fired for a false reason: in a linked worktree the app-side path resolves to the worktree'spackages/corewhile the server resolves the primary's through linkednode_modules. Two paths, same package, no broken seam.Path comparison is not a sound proxy for a split module instance, and a boot warning that misfires on this repo's own documented worktree workflow is worse than none. Making it sound would mean functionally probing the seam at boot, which costs a second core import to catch a case the peer fix already prevents. So it is gone.
Test plan
packages/server/test/core-peer-dependency.test.js(new, 2/2) pins the packaging decision and records the failure mode so nobody moves it back.asset-helper-serve.test.jspasses 2/2 with the split removed. It was 1/2 before.test/bun/listener*, threedifferential-elisionassertions), each unrelated and reproducible with the branch source reverted toorigin/main.asset()fingerprints correctly (?v=99a5f9074737);webjs checkclean.Docs
.agents/skills/webjs/references/built-ins.mdgains a paragraph onasset()being a provider seam, why a second core breaks it silently along withcspNonce()and bound forms, andnpm ls @webjsdev/coreplusnpm dedupeas the diagnostic.Note
The regenerated lockfile also picks up core
0.7.52, which the release commit bumped without the lock following. Unrelated to this fix, called out so it is not a surprise in the diff.