Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions .agents/skills/webjs/references/built-ins.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ html`<link rel="stylesheet" href=${asset('/public/app.css')}>`

That emits `/public/app.css?v=<hash>` 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 `<form action=${fn}>` 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 `<link rel="stylesheet" href="/public/app.css">` 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).
Expand Down
9 changes: 7 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"README.md"
],
"dependencies": {
"@webjsdev/core": "^0.7.51",
"ws": "^8.20.0"
},
"publishConfig": {
Expand All @@ -54,5 +53,11 @@
},
"optionalDependencies": {
"amaro": "^1.1.10"
},
"peerDependencies": {
"@webjsdev/core": "^0.7.51"
},
"devDependencies": {
"@webjsdev/core": "^0.7.51"
}
}
45 changes: 45 additions & 0 deletions packages/server/test/core-peer-dependency.test.js
Original file line number Diff line number Diff line change
@@ -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 `<form action=${fn}>` 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');
});
Loading