From 7e4c04bde89015c20ffd34dcca332f30c8be97db Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 21 Aug 2026 17:58:09 +0530 Subject: [PATCH] fix: make @webjsdev/core a peer dependency of @webjsdev/server 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
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. --- .agents/skills/webjs/references/built-ins.md | 2 + package-lock.json | 9 +++- packages/server/package.json | 7 ++- .../server/test/core-peer-dependency.test.js | 45 +++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 packages/server/test/core-peer-dependency.test.js diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index 3aaabb757..0feef7f0d 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -93,6 +93,8 @@ html`` That emits `/public/app.css?v=` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, so this is a convention rather than a `webjs check` rule (`webjs doctor` does flag the plain form, see below). Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched. +`asset()` is a PROVIDER SEAM: `@webjsdev/server` installs the resolver at boot by importing `@webjsdev/core` and calling a setter, and that only reaches your app when both sides load the SAME copy of core. Two copies on disk are two independent sets of module-scope state, so the setter lands on one and your `asset()` reads the other, which returns bare paths and never says why. `cspNonce()` and the bound-form identity resolver behind `` sit on the same seam and go inert together. `@webjsdev/server` therefore declares core as a PEER dependency, so npm resolves it against your app's copy and reports a genuine conflict at install time rather than nesting a second one. If you ever do end up with two (a hand-rolled install, a vendored copy), the symptom is those three features quietly doing nothing rather than any error, so reach for `npm ls @webjsdev/core` and `npm dedupe` before looking anywhere else. + Forgetting it is the one real cost of opt-in, so `webjs doctor` catches it: a page, layout, or error boundary writing a plain `` gets a WARN naming the `file:line` and the fix (#1095). It reads your source and rewrites nothing, and it stays quiet about the non-marks that are deliberate: a cross-origin sheet, a `rel="icon"`, a `rel="preload"`, and any `href=${expr}` hole. Same posture as Rails (a `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url from the build graph, surfaced through `links()`): take the fingerprint at the point the url is PRODUCED, never by rewriting a rendered document. A warning is easy to miss, so make it fatal in the app that cares: gate `UNMARKED_ASSET_LINKS` to `error` (see the doctor severity gate below) and one `npm run doctor` step in CI stops the un-versioned url reaching a deploy. The scaffold ships exactly that. It is opt-in rather than automatic because only the author knows which urls are the REQUEST. Do NOT mark a `rel="preload"` hint whose asset is actually fetched by CSS `url()`: the preload cache is keyed on the full url, so a versioned hint can never satisfy the unversioned request the stylesheet makes, and the file is fetched twice. Mark the thing that fetches, not the hint. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing). diff --git a/package-lock.json b/package-lock.json index b84e63df5..ca1b7b4c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6995,7 +6995,7 @@ }, "packages/core": { "name": "@webjsdev/core", - "version": "0.7.51", + "version": "0.7.52", "license": "MIT", "devDependencies": { "esbuild": "^0.25.0" @@ -7047,14 +7047,19 @@ "version": "0.8.65", "license": "MIT", "dependencies": { - "@webjsdev/core": "^0.7.51", "ws": "^8.20.0" }, + "devDependencies": { + "@webjsdev/core": "^0.7.51" + }, "engines": { "node": ">=24.0.0" }, "optionalDependencies": { "amaro": "^1.1.10" + }, + "peerDependencies": { + "@webjsdev/core": "^0.7.51" } }, "packages/server/node_modules/ws": { diff --git a/packages/server/package.json b/packages/server/package.json index 051d98c81..f394d48ba 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,7 +28,6 @@ "README.md" ], "dependencies": { - "@webjsdev/core": "^0.7.51", "ws": "^8.20.0" }, "publishConfig": { @@ -54,5 +53,11 @@ }, "optionalDependencies": { "amaro": "^1.1.10" + }, + "peerDependencies": { + "@webjsdev/core": "^0.7.51" + }, + "devDependencies": { + "@webjsdev/core": "^0.7.51" } } diff --git a/packages/server/test/core-peer-dependency.test.js b/packages/server/test/core-peer-dependency.test.js new file mode 100644 index 000000000..dc276ee9f --- /dev/null +++ b/packages/server/test/core-peer-dependency.test.js @@ -0,0 +1,45 @@ +/** + * `@webjsdev/core` must stay a PEER dependency of `@webjsdev/server`. + * + * Three of core's server-facing features are provider seams held in MODULE + * scope: `asset()` (`asset-url.js`), `cspNonce()` (`csp-nonce.js`), and the + * bound-form identity resolver (`form-action.js`). The server installs each at + * boot by importing core and calling a setter, and that 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 when that happens, which is what makes it worth a guard: + * `asset()` returns bare paths and quietly loses its `immutable` caching, + * `cspNonce()` returns empty so an inline script is blocked under a CSP, and + * `formActionId` answers null so a bound `` loses the + * identity the dispatcher reads. It was found from the far end, as an + * `asset-helper-serve` failure whose only symptom was a url with no `?v=`. + * + * A regular `dependencies` entry lets npm nest a second copy under + * `@webjsdev/server` 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 instead of being silently satisfied by duplication. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PKG = JSON.parse(readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')); + +test('core is a peer dependency, not a regular one', () => { + assert.ok(PKG.peerDependencies?.['@webjsdev/core'], + 'core must be declared as a peer so npm resolves it against the app copy'); + assert.equal(PKG.dependencies?.['@webjsdev/core'], undefined, + 'a regular dependency lets npm nest a SECOND core, which splits the provider seams'); +}); + +test('the peer range is also carried as a dev dependency', () => { + // Workspace development and the test suite both import core directly, and a + // peer alone is not installed for this package in isolation. + assert.equal(PKG.devDependencies?.['@webjsdev/core'], PKG.peerDependencies['@webjsdev/core'], + 'the dev range must track the peer range so local dev resolves what apps will'); +});