You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Plan written against HEAD 509d8809. Every LOC figure, line anchor, export count, and byte size below was measured at that commit. Re-derive anchors before editing.
Problem
Ten source files across @webjsdev/core, @webjsdev/server, and @webjsdev/cli have grown into single-file monoliths. Every figure below was re-measured with wc -l at HEAD 509d8809 and every one in the original body was correct.
File
LOC
What it holds
packages/core/src/router-client.js
5400
Client routing, prefetch, DOM diffing, stream parsing, forms, scroll restoration
SSR string render, DSD injection, SSR slot projection, entity decode, streaming render
packages/server/src/ssr.js
2394
Layout chain, head builder, preload emission, metadata, streaming response
packages/core/src/slot.js
2282
Light and shadow slot projection, polyfills, sensors, native write interception
packages/core/src/component.js
1896
WebComponent base class, lifecycle, reflection, server element shim
packages/server/src/check.js
1805
The webjs check rule engine and all 18 rules inline in one function
packages/cli/lib/doctor.js
1686
13 project-health probes, the severity policy, the runner
Total 26,329 lines. Runtime export counts, measured by importing each module at HEAD, are the contract any split has to preserve.
Module
Named exports
Of which _ test seams
router-client.js
68
63
slot.js
31
0
vendor.js
25
0
ssr.js
18
9
dev.js
16
0
doctor.js
9
0
render-server.js
2
0
component.js
2
0
check.js
2
0
render-client.js
1
0
The largest hidden cost is the relative-path import surface. Counting from '<path>/<file>.js' sites across packages/*/test, test/, packages/*/src, and the in-repo apps gives 214 import sites for the ten files. Per file, counting distinct TEST files only.
Module
Test files importing it by relative path
dev.js
62
component.js
36
router-client.js
32
render-client.js
22
render-server.js
12
check.js
11
vendor.js
6
slot.js
5
ssr.js
3
doctor.js
2
Many of those import INTERNAL symbols, not the public API. The full internal-symbol contract, extracted mechanically from the import clauses, is recorded per phase below.
What was stale or wrong in the previous body
The 800-LOC acceptance criterion contradicted itself. It said "no single source file in the core packages exceeds 800 lines" while four files outside the list of ten already break it at HEAD, and the issue proposed no work on them. Verified counts: packages/core/src/html-entities.js 2179, packages/cli/bin/webjs.js 1665, packages/cli/lib/create.js 1636, packages/editors/intellisense/src/index.js 1493, packages/server/src/component-elision.js 1374, packages/core/src/form-action.js 1258. The criterion is restated in a mechanically checkable form under Design.
The comment proposing a rename to src/client-router.js is superseded. It contradicts packages/core/package.json, whose ./client-router subpath maps "source": "./src/router-client.js" and "types": "./src/router-client.d.ts". Reasoning under Design.
The prior-art table overstated three of its five claims. Corrections under Design, each cited by the file actually read in the clone.
src/dev/ is a misleading directory name for what dev.js holds, because startServer (line 1729) is the PRODUCTION entry too and the 16 read*FromApp config readers (lines 181-535) run in both modes. The name is kept anyway for a stated mechanical reason, with the caveat recorded.
The issue's proposed module lists were guesses. Every one is replaced below with a list derived from the functions actually in each file, with current line ranges.
Design / approach
D1. This ships as nine independently mergeable PRs, all closing this issue
A ten-file, 26,000-line refactor in one PR is unreviewable and unbisectable. If any one of the ten splits regresses SSR bytes or drops an export, a single PR gives no way to bisect which. One subsystem per PR is the norm for this shape and is what every cited prior art did incrementally.
Every PR body carries Closes #1365, so the last one to merge closes it. Do NOT file follow-up issues for anything found on the way. Report it in the PR body and leave it.
Order, by ascending blast radius, with each phase establishing mechanics the next one reuses.
#
Subsystem
Package
Why here
1
lib/doctor.js
cli
2 test import sites, 13 already-independent probe functions, never in a browser bundle, never on a request path. The cheapest place to establish the barrel pattern, the export-count floor, and the cycle check
2
src/vendor.js
server
6 test import sites, clean function seams, server-only, no request-path coupling. Establishes the pattern on a 2400-line file
3
src/check.js
server
11 test import sites but only 2 exports. The one phase that is a real restructure rather than a move, so it goes before anything with SSR-byte or bundle risk
4
src/router-client.js
core
Self-contained (nothing in packages/core/src imports it except the two index entries), but 32 test files, 63 _ seams, and the first phase with browser-bundle exposure
5
src/render-client.js
core
22 test files, 1 export, and the hardest cycle problem in the repo. Goes after the router so the cycle discipline is already proven
6
src/slot.js
core
5 test files, 31 exports, imported by render-client.js, component.js, and router-client.js. Goes after both consumers are split so its import surface is already stable
7
src/component.js
core
36 test files and one 1334-line class, which cannot be split by moving functions. Needs the method-body extraction described in P7
8
src/render-server.js + src/ssr.js
core + server
Coupled through the SSR byte contract and the streaming protocol. Move together
9
src/dev.js
server
Largest blast radius by far (62 test files), the boot path of every app, runtime-sensitive on Bun, and the only file whose main function must be decomposed rather than moved. Last
Phases 8 and 9 are the only pair that must move together, and they are 8.packages/server/src/ssr.js:3 imports renderToString from the @webjsdev/core package specifier, not from a relative path, so the two are not coupled at module level. They are coupled through the bytes: ssr.js drives renderToString / renderToStream and then post-processes the HTML those produce (DSD injection, <webjs-suspense> processing, head hoisting, boundary markers). One PR gives one SSR-bytes verification pass over both halves instead of two passes that each leave the other half unproven.
D2. Naming principle, settled once for all ten
The monolith's path stays and becomes the barrel. New modules live in a sibling directory named after the barrel's basename. So packages/core/src/router-client.js remains, and its parts land in packages/core/src/router-client/.
Alternative REJECTED: rename to the public-export name (src/client-router.js), which is what the single existing comment on this issue proposed. Rejected on measured cost.
packages/core/package.json maps ./client-router to "source": "./src/router-client.js" and "types": "./src/router-client.d.ts". A rename changes the published manifest for zero behaviour.
There are 26 hand-written .d.ts overlays in packages/core/src/, and BOTH type guards derive the runtime sibling from the overlay path by the sibling rule (foo.d.ts overlays foo.js), stated in the header of test/types/dts-export-coverage.test.mjs and again in test/types/dts-no-phantom-exports.test.mjs. Renaming the .js without the .d.ts breaks that derivation silently.
packages/core/test/routing/client-router-export-parity.test.js imports '../../src/router-client.js' AND reads src/router-client.d.ts off disk by name.
website/app/docs/no-build/page.ts:51 prints the literal string "@webjsdev/core/client-router": "/__webjs/core/src/router-client.js" as a docs example of the emitted importmap.
packages/core/AGENTS.md names src/router-client.js at lines 84 and 274, and framework-dev.md:176 names it as the home of the 63 _ seams.
214 in-repo import sites across the ten files, 32 of them for this file alone.
Keeping the path makes every phase's diff purely additive at the import layer. No existing import, .d.ts, manifest entry, docs sample, or type guard has to change.
The barrels are not compatibility shims. WebJs has no users, so a shim would be theatre. Each barrel is justified by the import sites it keeps working inside this repo (62, 36, 32, 22, 12, 11, 6, 5, 3, 2 test files respectively) and by the published exports manifest, which names four of the ten paths directly. Beyond the barrel, ship NO compatibility layer: no deprecated aliases, no old-path stubs, no re-export of a name the split renamed.
packages/core/package.jsonfiles needs no change. It lists "src" wholesale, so a new subdirectory publishes automatically. Same for packages/server ("src") and packages/cli ("lib"). Verified against all three manifests.
One caveat, recorded rather than fixed.packages/server/src/dev/ reads as dev-only and is not, since startServer and the 16 boot config readers live there and run in production. Renaming to src/server/ would cost 62 test import sites and a package.json-adjacent rename for zero behaviour, so the mechanical rule wins. Say so in a banner comment at the top of the barrel.
D3. The 800-LOC criterion, settled
Scope. The criterion binds ONLY the ten files this issue splits and the modules produced from them. No other file in the repo is bound by it, and none of them is in scope.
Rule. A produced module targets 800 lines and MUST NOT exceed 1000. A barrel is exempt (its length is a function of its export count, and router-client.js alone re-exports 68 names). A module that would need an artificial seam to get under 1000 is exempt only if it is named in this plan with its measured size and its reason. Exactly one such exemption is anticipated, packages/core/src/component/base.js, and P7 states the fallback that avoids it.
Mechanically checkable form, run once at the end of the sequence.
Why not a hard 800, and why no CI guard. Every framework this issue cites as its model exceeds 800 in its own core modules, measured in the local clones: lit/packages/reactive-element/src/reactive-element.ts is 1754, lit/packages/lit-html/src/lit-html.ts is 2303, vite/packages/vite/src/node/server/index.ts is 1447, vite/packages/vite/src/node/optimizer/index.ts is 1487. Those projects draw their seams by responsibility, not by line count. A LOC gate in CI is a proxy metric that fights cohesion: it would red on packages/core/src/html-entities.js forever, and it would have to encode an exemption list that rots. Do not add one. The criterion is a one-time acceptance check, verified by the command above.
Data tables are exempt by nature.packages/core/src/html-entities.js (2179 lines) is the WHATWG named-character-reference table plus the legacy semicolon-less names. It has no seams, splitting it changes no byte, and it is not in scope.
D4. Cycle and TDZ discipline
Splitting a file whose functions call each other in both directions produces ESM cycles. Node tolerates a cycle, but reading a const or class binding during the cycle's evaluation phase throws a TDZ ReferenceError at module load, and in the minified browser bundle that surfaces as a blank page rather than a test failure.
Baseline, measured. There are currently zero import cycles across all 119 modules in packages/core/src, packages/server/src, and packages/cli/lib. So the invariant is "still zero", which is checkable and currently true.
Discipline.
Layer the modules and never import upward. Leaf layer holds constants and module state with no sibling imports. Middle layer holds pure helpers. Top layer holds orchestration. The barrel sits above everything.
Where a genuine back edge exists, resolve it by late binding through a function call at call time, never a top-level read. A const X = other.thing or a class A extends other.B at module scope is the shape that throws.
Where mutual recursion is inherent (the render-client.js apply and instance group is the one real case), introduce an explicit dispatch registry module that both sides import, with the implementations registered at module load. P5 specifies it.
Per-phase check. Write this to your scratchpad and run it after each phase. It takes no dependency and writes nothing into the repo.
The MINIFIED-bundle half of the risk is covered by npm run test:browser, which boots the built bundle in real Chromium. That is why the browser suite is mandatory on every core phase even though the change is behaviour-preserving.
D5. The published dist/ bundle is a hard gate
scripts/build-framework-dist.js bundles four entries (index.js, index-browser.js, src/lazy-loader.js, src/testing.js) with esbuild at bundle: true, splitting: false, treeShaking: true, minify: true, plus its own sanity check that every entry produced its expected output filename.
Three consequences the implementer must encode.
(a) e2e and Bun tests resolve the BUILT bundle. A src-only edit is invisible to them until dist is rebuilt, so a counterfactual passes vacuously. Every core phase rebuilds first.
node scripts/build-framework-dist.js
(b) Bundle size is a gate with a stated tolerance. Record webjs-core-browser.js byte size before and after each core phase.
stat -c%s packages/core/dist/webjs-core-browser.js packages/core/dist/webjs-core.js
Baseline at HEAD 509d8809 is 137,038 bytes for webjs-core-browser.js and 189,088 for webjs-core.js.
Tolerance is +1.0% per core phase and +2.0% cumulative across phases 4 through 8. Reasoning: a pure module split should be byte-neutral after bundling and minification, since the statements are identical and only their file boundaries moved. The two legitimate sources of drift are esbuild disambiguating an internal identifier that now collides across modules, and code that esbuild previously dropped inside one module but can no longer drop once it crosses a module boundary as an export. The second is exactly the regression worth catching, so the tolerance has to be tight enough to surface it. One percent of 137 KB is about 1.4 KB, roughly one medium function, which is tight enough to catch a defeated tree-shake and loose enough not to red on identifier renaming. A phase over tolerance must explain the delta in its PR body before merging.
(c) The build script's own sanity check must still pass. It throws when an entry name goes missing. Since no entry path changes in this plan, a failure here means a barrel stopped resolving.
D6. Dev-time serving needs no change (investigated, evidence recorded)
In dev with no built dist, the browser fetches core source files individually rather than the bundle. Do not go hunting; this was resolved.
packages/server/src/importmap.js maps the prefix '@webjsdev/core/' to '/__webjs/core/src/' (lines 306 and 397). It is a PREFIX mapping, not a file enumeration.
packages/server/src/dev.js:2011 serves /__webjs/core/* by resolving the remainder against the resolved core directory behind a trailing-separator traversal guard. A nested path such as /__webjs/core/src/router-client/state.js is served with no code change.
Relative imports inside the split modules resolve against the served URL, so no importmap entry, no scope, and no module-graph change is needed.
Exactly ONE modulepreload hint is emitted for core (packages/server/src/ssr.js:1718-1729, the bare @webjsdev/core importmap target), not one per file, so nothing enumerates core's file list.
The one real dev-only cost. Splitting a core file into N modules adds N requests at one extra graph depth on a dev page load, because ES module resolution is a waterfall by depth. This is localhost, dev-only, and disappears in production, where dist mode serves one bundle. It is accepted, not mitigated. Do not add a dev-mode bundler.
D7. Elision is both a risk surface and the best verification tool
packages/server/src/component-elision.js and packages/server/src/js-scan.js decide what ships to the browser. Checked, and the finding is narrow: neither matches on any core source filename. The single occurrence of component.js in component-elision.js is at line 35 inside a prose doc comment, and js-scan.js contains no reference to a core src filename at all. The analysers work on the APP's module graph and on @webjsdev/core as a package specifier, which no phase changes.
Elision is therefore used here as the verification tool it is. npx webjs elision --verify renders every static page route with elision on and off and diffs the observable SSR bytes. Run it in all three in-repo apps on every phase that touches SSR, rendering, or the client bundle (phases 4 through 9).
( cd gallery && npx webjs elision --verify )
( cd examples/blog && npx webjs elision --verify )
( cd website && npx webjs elision --verify )
What it does NOT cover. It proves the bytes you SERVE did not change. It says nothing about post-hydration behaviour, because a wrongly dropped module is a dead click, not different bytes. That gap is why npm run test:browser and the e2e suite still have to run on every core phase.
D8. Prior art, verified against the clones and corrected
Clones live at ~/Documents/Projects/frameworks/. Every claim below was read, not assumed.
Claim in the old table
Verdict
What the clone actually contains
Turbo turbo/src/core/drive/ splits into visit.js, navigator.js, page_renderer.js, snapshot_cache.js, form_submission.js, head_snapshot.js
Confirmed, and understated
All six exist. The directory also holds prefetch_cache.js, preloader.js, view_transitioner.js, progress_bar.js, history.js, page_view.js, page_snapshot.js, error_renderer.js, morphing_page_renderer.js, limited_set.js, which map one-to-one onto WebJs router concerns the old table omitted (prefetch, view transitions, nav progress, DOM morphing). Largest module is visit.js at 419 lines; the median is under 150. This is the closest match to the P4 target
Next router-reducer/ splits into navigate-reducer.ts, restore-reducer.ts, server-action-reducer.ts
Confirmed, path corrected
The clone is next.js/, not next/, and the three files live under router-reducer/reducers/, alongside refresh-reducer.ts, server-patch-reducer.ts, hmr-refresh-reducer.ts, find-head-in-cache.ts, committed-state.ts. navigate-reducer.ts is 56 lines and router-reducer.ts is 69. The seam is a pure state-transition dispatch, which WebJs has no equivalent of (its router mutates the DOM directly), so this informs the state.js and navigator.js seam in P4 and nothing else
Lit decouples reactive-element (lifecycle, props, controllers) from lit-html (templates, parts) and directives/
Confirmed at PACKAGE level, and it contradicts the 800-line criterion
lit/packages/reactive-element/src/ holds reactive-element.ts (1754 lines), reactive-controller.ts, css-tag.ts, decorators/. lit/packages/lit-html/src/ holds lit-html.ts (2303 lines), directive.ts, async-directive.ts, directive-helpers.ts, directives/. Lit draws the seam by RESPONSIBILITY across packages and leaves each core module large. This is the direct evidence behind D3
Vite server/ is modular (index.ts, hmr.ts, ws.ts, moduleGraph.ts, transformRequest.ts, send.ts, middlewares/)
Confirmed
All present, plus environments/, pluginContainer.ts, warmup.ts, sourcemap.ts. index.ts is 1447 lines and hmr.ts 1191, so again the orchestration entry stays large while the concerns split out. That is precisely the shape P9 targets
Vite optimizer decouples "scan, resolution, import map generation, and cache storage"
Two of four wrong
vite/packages/vite/src/node/optimizer/ holds index.ts (1487), optimizer.ts (808), scan.ts (797), resolve.ts, pluginConverter.ts, rolldownDepPlugin.ts. Scan and resolution are separate as claimed. There is no importmap generation in Vite's optimizer at all (Vite rewrites specifiers at transform time), and cache storage is not its own module, it lives inside index.ts and optimizer.ts. The corrected lesson for P2 is narrower: split the SCANNER from the RESOLVER, and expect the orchestration entry to stay the largest module
Remix @remix-run/server-runtime separates "layout matching, boundary stream construction, and head metadata extraction"
One of three wrong
remix-v2/packages/remix-server-runtime/ holds routeMatching.ts (29 lines), routeModules.ts (268), links.ts (192), entry.ts (58), markup.ts (19), serverHandoff.ts (30), with server.ts at 813 as the orchestrator. Matching and head metadata are separate as claimed. Boundary stream construction is not in that package, it lives in remix-v2/packages/remix-react/components.tsx (1356 lines) and server.tsx. The corrected lesson for P8 is that tiny single-purpose modules (routeMatching, markup, serverHandoff) around a large orchestrator is the achievable shape, not an evenly-sized fan
D9. The export contract, and the one new test
The failure mode this refactor introduces is a silently dropped export. Three guards already exist and cover most of it.
test/types/dts-no-phantom-exports.test.mjs proves every value an overlay declares EXISTS at runtime, per published exports subpath. A dropped PUBLIC export fails here by name.
test/types/dts-export-coverage.test.mjs proves the reverse per subpath.
packages/core/test/routing/client-router-export-parity.test.js derives the public router surface from src/router-client.d.ts and asserts it on the module, on index.js, and on index-browser.js.
Every internal _ seam is guarded implicitly, because the test importing it fails at LINK time with "does not provide an export named" when it disappears.
What none of them catches is a barrel that quietly carries FEWER seams than before while every currently-written test still happens to pass. Add one test, test/architecture/barrel-surface.test.mjs, holding a per-barrel export-COUNT floor. A count floor is not a name list, so it does not rot as exports are added, and it is the same anti-vacuum device dts-export-coverage.test.mjs already uses for its minNames floors. Floors are the counts measured at HEAD.
Barrel
Floor
packages/core/src/router-client.js
68
packages/core/src/slot.js
31
packages/server/src/vendor.js
25
packages/server/src/ssr.js
18
packages/server/src/dev.js
16
packages/cli/lib/doctor.js
9
packages/core/src/render-server.js
2
packages/core/src/component.js
2
packages/server/src/check.js
2
packages/core/src/render-client.js
1
The test is added in phase 1 with all ten entries live from the start, since all ten modules exist at HEAD and the floors hold before any split. That way every later phase inherits the guard rather than adding it.
Per-phase mechanical proof, in addition to the test. Before opening each PR, diff the barrel's export list against the pre-split module's and paste the (empty) diff into the PR body.
git show origin/main:packages/core/src/router-client.js > /tmp/before.js # adjust path per phase
node --input-type=module -e " const a = await import('/tmp/before.js'); const b = await import('./packages/core/src/router-client.js'); const A = Object.keys(a).sort(), B = Object.keys(b).sort(); const missing = A.filter(k => !B.includes(k)), added = B.filter(k => !A.includes(k)); console.log(JSON.stringify({ missing, added }));"
missing must be empty. added must be empty too, because a behaviour-preserving split adds no public surface.
D10. Two enforcement hooks need edits, one does not
.claude/hooks/require-bun-parity-with-runtime-src.sh decides that a staged path is runtime-sensitive by matching this regex against the path.
packages/server/src/dev/app-config.js matches none of those alternatives, and neither does packages/server/src/ssr/head.js. Splitting either file would silently disable the Bun parity gate for the resulting modules. Fix it in the phase that causes it, by widening the two exact-file alternatives so they also match a directory.
In phase 8, change /ssr\.js to /ssr[./].
In phase 9, change /dev\.js to /dev[./].
Neither widening captures an unrelated sibling (dev-error.js has a - after dev, not a . or /). render-server already matches without anchors, so packages/core/src/render-server/dsd.js stays covered with no edit.
.claude/hooks/require-docs-with-src.sh matches ^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, which already matches every nested path. No edit.
.claude/hooks/require-tests-with-src.sh is satisfied by every phase, since each one touches test files.
Implementation plan
Before every phase, without exception.
git fetch origin
git worktree add -b refactor/1365-<slug> ../webjs-1365-<slug> origin/main
cd ../webjs-1365-<slug>
npm run worktree:link
The line anchors in this plan are dated to HEAD 509d8809. Re-derive every range with grep -n in your worktree before editing. A phase that lands weeks after this was written will find them moved.
Push the branch and open a DRAFT PR immediately, with Closes #1365 in the body. Mark it ready only after the phase's full verification set is green and pasted.
Phase 1. packages/cli/lib/doctor.js (1686 LOC)
Barrel packages/cli/lib/doctor.js. Modules in packages/cli/lib/doctor/.
Current internal structure and the seams it dictates.
Barrel re-exports the 9 current exports: DOCTOR_SEVERITIES, DOCTOR_CODES, codeForName, readDoctorPolicy, applyDoctorPolicy, readAppBasePath, frameworkResolves, checkFrameworkResolves, runDoctorChecks.
Layering, bottom to top: codes.js and util.js are leaves; policy.js, manifest.js, route-modules.js sit above them; every probes/*.js imports downward only; runner.js imports the probes and codes.js; the barrel imports everything.
The one non-move step.runDoctorChecks currently attaches r.code = codeForName(r.name) after the Promise.all. Keep that in runner.js exactly as is. Do NOT move code assignment into the probes, which would be a behaviour change disguised as a refactor.
The internal symbol the tests import is readAppBasePath only (packages/cli/test/), so the barrel's contract is small. packages/cli/bin/webjs.js:431 and :782 dynamically import '../lib/doctor.js', which the barrel keeps working unchanged.
Also in this PR: add test/architecture/barrel-surface.test.mjs with all ten floors from D9.
Verification for phase 1.
node scripts/find-cycles.mjs packages/cli/lib # the D4 script, from your scratchpad
npm test
( cd gallery && npx webjs doctor && npx webjs check )
( cd examples/blog && npx webjs doctor && npx webjs check )
( cd website && npx webjs doctor && npx webjs check )
node --test packages/cli/test/**/*.test.js
Browser, e2e, Bun, and elision do NOT apply. lib/doctor.js never reaches a browser, never runs on a request path, and is not on the Bun parity hook's runtime-sensitive list.
Phase 2. packages/server/src/vendor.js (2430 LOC)
Barrel packages/server/src/vendor.js. Modules in packages/server/src/vendor/.
Current lines
Functions
Goes to
Target LOC
67-99, 100-237
BUILTIN, FRAMEWORK_SERVER_ONLY, scanBareImports, extractPackageName, IMPORT_RE, DYNAMIC_IMPORT_RE, stripComments, isServerOnlyFile, CONFIG_FILE_RE, walk
Correction to the issue's proposed list. The issue proposed resolver.js, jspm.js, importmap-builder.js, disk-cache.js. There is no importmap BUILDER in this file (packages/server/src/importmap.js is a separate module that builds the map; vendor.js only produces the vendor ENTRIES that feed it, in vendorImportMapEntries). And "disk cache" is really two unrelated things, the pin FILE at .webjs/vendor/importmap.json and the downloaded BUNDLE files, which have different lifecycles. The list above replaces both names. This matches the corrected Vite lesson in D8, where the real seam is scanner-versus-resolver and the orchestration entry stays largest.
Barrel re-exports the 25 current exports. The test import surface is exactly those 25 names across 6 test files, so the barrel is a pure re-export list.
Layering: semver.js and scan.js are leaves; manifest.js, pin-file.js, pin-prune.js above them; jspm.js, download.js, registry.js, coherence.js above those; pin-commands.js and serve.js on top.
Watch for one real coupling.clearVendorCache (line 707) clears jspmCache and is imported by packages/server/src/dev.js:51. It must stay in the module that OWNS jspmCache (vendor/jspm.js), not in a general cache module, or the clear silently stops clearing.
Verification for phase 2.
node scripts/find-cycles.mjs packages/server/src
npm test
node --test packages/server/test/**/*.test.js
( cd gallery && npx webjs doctor ) # exercises checkImportmapCoherence + checkVendorPin
( cd examples/blog && npx webjs doctor )
( cd website && npx webjs doctor )
Browser and e2e do NOT apply (server-only, no request-path behaviour). Bun parity does NOT apply (no path in this phase matches the hook's runtime-sensitive regex, and the vendor engine runs identically on both runtimes through plain fetch and node:fs). Elision does NOT apply.
The vendor suite uses a JSPM double (framework-dev.md:204), which records refusals rather than throwing, because every fetch caller in this file catches. A split that misroutes a call site therefore shows up as a recorded refusal, not a thrown test. Read the recorded refusals in the test output, do not just check the exit code.
Phase 3. packages/server/src/check.js (1805 LOC)
Barrel packages/server/src/check.js. Modules in packages/server/src/check/.
This is the only phase that is a genuine restructure. checkConventions spans lines 547-1471 (925 lines) with all 18 rules inline, delimited by // --- Rule: <name> --- comments. The RULES array at 57-160 is metadata only; it does not dispatch. So the target architecture is the ESLint shape: one shared scan context built once, then one module per rule group that receives the context and pushes violations.
no-server-import-in-browser-module (with findImportChain and checkServerImportInBrowserModule), no-missing-local-import
check/rules/imports.js
330
547-571 plus the dispatch
checkConventions orchestration
check/run.js
140
header 1-56
imports and the file banner
stays on the barrel
30
Correction to the issue's proposed list. The issue proposed rules/, ast-scanner.js, reporter.js. There is no reporter in this file. Reporting lives in packages/cli/bin/webjs.js, which formats what checkConventions returns. And the scanning is not an AST scanner, it is regex and text scanning over source built on packages/server/src/js-scan.js, which is already a separate module. check/ast-helpers.js above is named for what it is, a set of source-shape predicates, and js-scan.js is left alone.
The invariant to preserve.checkConventions returns a flat violations array in a stable order. Rule modules must be invoked in the SAME order they run today, or a snapshot-shaped assertion in the check suite reorders and fails for a non-reason. Record the order in check/run.js with a comment naming this constraint.
RULES is imported by test/knowledge/knowledge-coverage.test.js:22, so it must stay on the barrel.
Verification for phase 3.
node scripts/find-cycles.mjs packages/server/src
npm test
node --test packages/server/test/**/*.test.js
node --test test/knowledge/knowledge-coverage.test.js
npx webjs check --rules # from any app dir, prints the rule list
( cd gallery && npx webjs check )
( cd examples/blog && npx webjs check )
( cd website && npx webjs check )
npx webjs check --rules output must be byte-identical to origin/main. Capture both and diff.
Browser, e2e, Bun, and elision do NOT apply. check.js is a static analyser invoked by the CLI; it is not on a request path and not on the Bun parity list.
Barrel packages/core/src/router-client.js. Modules in packages/core/src/router-client/.
This is the first phase with browser-bundle exposure. Nothing inside packages/core/src imports this file (only index.js:31 and index-browser.js:55,63 do), so it is self-contained in the import graph, which is why it goes before the other core files.
the imports, the export block, the 63 _ seams, the module-scope auto-enable
stays on the barrel
175
Twenty modules plus the barrel. Largest is dom-differ.js at about 710.
Correction to the issue's proposed list. The issue proposed ten modules and named form-encoder.js, dom-parse.js, scroll.js, prefetch.js, dom-differ.js, stream-processor.js, navigator.js, events.js, constants.js, state.js. Eight of those are right and kept. stream-processor.js is renamed to stream.js, since it does not process a stream so much as read the shell and apply resolve units. The list was missing six real concerns the code actually contains, each large enough to matter: the swap (345), head merging (330), diagnostics and the fallback event (340), frames and busy markers (320), view transitions and permanent regraft (220), and the boundary scan (200). Those are added above. Turbo's drive/ directory, verified in D8, has a directly comparable module for four of the six (view_transitioner.js, head_snapshot.js, page_renderer.js, progress_bar.js), which is the evidence they are real seams rather than invented ones.
Layering, strictly downward.constants.js is the only true leaf. state.js imports constants.js. Then dom-parse.js, scroll.js, snapshot-cache.js, form-encoder.js, boundaries.js, upgrade.js. Then diagnostics.js, frames.js, head-merge.js, dom-differ.js, view-transition.js, stream.js, nav-error.js. Then swap.js, which is the one that pulls the widest. Then fetch-apply.js, then navigator.js, then events.js. The barrel imports navigator.js and events.js and re-exports everything.
The one back edge to plan for.applySwap calls into upgradeCustomElementsInRange (view transitions) and mergeHead, and fetchAndApply calls applySwap, while performNavigation calls fetchAndApply and the prefetch engine calls back into the snapshot cache. Every one of those points downward in the layering above. The single edge that does NOT is reportFallback in diagnostics.js reaching hardNavigate, which lives in state.js, one layer below it. That resolves cleanly because hardNavigate is a mutable binding read through an accessor, so state.js exposes getHardNavigate() and never a top-level const.
The 63 _ seams stay on the barrel, unchanged in name.framework-dev.md:176 and packages/core/AGENTS.md:84 both name this file as their home, and the _ prefix is what exempts them from the two .d.ts guards. Do not move them into sub-modules and do not rename them.
Verification for phase 4.
stat -c%s packages/core/dist/webjs-core-browser.js # record BEFORE
node scripts/find-cycles.mjs packages/core/src
node --input-type=module -e "await import('./packages/core/index-browser.js'); console.log('ok')"
node scripts/build-framework-dist.js
stat -c%s packages/core/dist/webjs-core-browser.js # record AFTER, tolerance +1.0%
npm test
npm run test:browser
WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs test/e2e/dev-seed-observability.test.mjs \
test/e2e/nested-layout-partial-swap.test.mjs test/e2e/form-submission-and-race.test.mjs \
test/e2e/dev-overlay-nav.test.mjs test/e2e/browser-harness.test.mjs
node scripts/run-bun-tests.js
( cd gallery && npx webjs elision --verify && npx webjs check )
( cd examples/blog && npx webjs elision --verify && npx webjs check )
( cd website && npx webjs elision --verify && npx webjs check )
Barrel packages/core/src/render-client.js. Modules in packages/core/src/render-client/.
This file exports exactly ONE name, render, so the export contract is trivial. The difficulty is entirely internal: the apply and instance functions are mutually recursive in a genuine cycle.
Correction to the issue's proposed list. The issue proposed template.js, expressions.js, directive-runtime.js. Those three names cover perhaps a third of the file and hide the two largest concerns, the part-type appliers (repeat, array, cache, until, watch, async, about 1145 lines together) and the form-action binding machinery (about 300 lines, which exists because of invariant 12 and has no analogue in Lit). The list above replaces them. The Lit seam cited in the old table is a PACKAGE-level split between element and template, not a within-template-engine split, so it does not prescribe these names (see D8).
The cycle, and the fix.applyPart calls applyChildInner, which calls applyChildInnerRaw, which calls buildDetached and createInstance, which call bindPart and updateInstance, which call applyPart. Every part-type applier (reconcileRepeat, reconcileArray, applyCache, applyUntil, applyWatch, the two async ones) also calls back into applyChildInner or buildDetached. Moving these into separate modules by hand produces a dense cycle.
Introduce render-client/dispatch.js, a tiny module holding mutable function slots and a registerRenderInternals({ applyPart, applyChildInner, buildDetached, createInstance, disposeInstance }) setter, plus thin call-through wrappers. Leaf modules import the WRAPPERS from dispatch.js and call them; instance.js and apply-part.js call registerRenderInternals at module load. Every edge into dispatch.js is downward, every read happens at call time rather than at module evaluation, and the TDZ class disappears. This is the same late-binding shape Lit uses for private-ssr-support.ts, which exists precisely so lit-html internals can be reached without an import cycle.
Do not change what render does. The barrel keeps render as its own body rather than re-exporting it, so the one public name has one definition site.
Verification for phase 5: identical command set to phase 4. The browser suite matters most here, since a TDZ fault in the minified bundle is invisible to Node.
Phase 6. packages/core/src/slot.js (2282 LOC)
Barrel packages/core/src/slot.js. Modules in packages/core/src/slot/.
Correction to the issue's proposed list. The issue folded slot.js into src/component/slot-engine.js, a single module. That is wrong on two counts. slot.js is imported by render-client.js:16, router-client.js:24, AND component.js:10, so it is not a component-private concern, and putting it under component/ inverts the dependency (the renderer would import from the component tree). It also cannot be one module at 2282 lines. It gets its own directory, as above.
Barrel re-exports all 31 current exports. The test import surface is 24 named symbols across 5 test files plus 3 in-package importers, all covered by the barrel.
Layering: symbols.js is the leaf, then polyfills.js and state.js, then sensors.js and assignment.js, then project.js, then interception.js.
Verification for phase 6: identical command set to phase 4. The slot browser suites (packages/core/test/slots/browser/*, 10 files) are the load-bearing layer here, and the native-parity contract test in particular is the counterfactual for any dropped write interception.
Barrel packages/core/src/component.js. Modules in packages/core/src/component/.
class WebComponentBase spans lines 518-1851, which is 1334 lines in ONE class body. A class body cannot be split across modules, so this phase is not a move. The approach is to extract method BODIES into free functions that take the host element as their first argument, leaving the class body as thin delegating methods. This is behaviour-preserving as long as every extracted function keeps the exact this semantics it had.
the class shell: static fields, the constructor, the thin lifecycle hooks (shouldUpdate, willUpdate, update, updated, firstUpdated, render), and the delegating one-line methods
component/base.js
370
Barrel re-exports WebComponent and prop.
Correction to the issue's proposed list. The issue proposed base.js, properties.js, controllers.js, slot-engine.js. Three of those survive. slot-engine.js is wrong and is handled in phase 6 (see the correction there). The list is missing the update scheduler, which is the single largest concern in the class at about 480 lines, and the server element shim at 240, which exists because SSR runs the constructor against a ServerElement rather than an HTMLElement and is entirely separable.
Stated fallback. If component/base.js still exceeds the 1000-line ceiling after the extraction, record it as the single named exemption under D3 rather than inventing a mixin chain. lit/packages/reactive-element/src/reactive-element.ts is 1754 lines for exactly this reason (verified in D8), so a large single class at this seam is the industry outcome, not a failure.
The invariant that makes or breaks this phase.packages/server/src/check.js enforces reactive-props-no-class-field and no-static-properties against user code, and component.js itself throws at runtime when an app declares static properties. The extraction must not change WHEN _assertFactoryProperties runs relative to _initializeProperties, or the throw moves and the check-rule tests disagree with the runtime.
Verification for phase 7: identical command set to phase 4, plus the type fixtures, which pin WebComponent's signature.
Phase 8. packages/core/src/render-server.js (2427) plus packages/server/src/ssr.js (2394)
Two barrels, two directories, one PR. They are not coupled by import (ssr.js:3 reaches core through the package specifier), they are coupled through the SSR byte contract and the streaming protocol. One PR gives one verification pass over both halves.
packages/core/src/render-server.js, modules in packages/core/src/render-server/.
Barrel re-exports renderToString and renderToStream.
Correction to the issue's proposed list. The issue folded both files into one packages/server/src/ssr/ directory with layout-chain.js, boundary-emitter.js, metadata.js, stream-builder.js, dsd-serializer.js. That is wrong on package boundaries: render-server.js is in @webjsdev/core and is reached through @webjsdev/core/server, so it cannot move into @webjsdev/server. The two directories above replace the single one. Within ssr.js, boundary-emitter.js does not correspond to anything, since boundary markers are emitted by wrapWithChildrenMarker inside the segment-path helpers, not by a dedicated emitter.
packages/server/src/ssr.js, modules in packages/server/src/ssr/.
Current lines
Functions
Goes to
Target LOC
845-1030
loadingSegmentPath, layoutSegmentPath, pageSegmentPath, regionRouteKey, wrapWithChildrenMarker and their export block
Barrel re-exports all 18 current exports including the 9 _ seams (_hoistHeadTags, _pageSegmentPath, _regionRouteKey, _wrapWithChildrenMarker, _extractUserShell, _buildDocumentParts, _escapeJsonLd, _jsonLdScript, and the fourth in the wrapWithChildrenMarker block).
Layering on the server side: util.js is the leaf, then segments.js, metadata.js, preloads.js, then document.js and head.js, then response.js, then layout-chain.js, then page.js.
Also in this PR. Widen the Bun parity hook regex from /ssr\.js to /ssr[./] per D10, or every future edit under packages/server/src/ssr/ silently escapes the gate.
The invariant to preserve. The keyed boundary comment pairs (<!--wj:children:<segment>:<route-key>-->) are the client router's strict scan target, and a single byte of drift degrades every soft navigation to a full page load. wrapWithChildrenMarker and regionRouteKey must produce byte-identical output. webjs elision --verify covers this directly, and so does test/bun/keyed-boundaries.mjs.
Verification for phase 8.
stat -c%s packages/core/dist/webjs-core-browser.js packages/core/dist/webjs-core.js # BEFORE
node scripts/find-cycles.mjs packages/core/src packages/server/src
node --input-type=module -e "await import('./packages/core/index.js'); console.log('ok')"
node scripts/build-framework-dist.js
stat -c%s packages/core/dist/webjs-core-browser.js packages/core/dist/webjs-core.js # AFTER
npm test
node --test test/ssr/*.test.js
npm run test:browser
WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs test/e2e/dev-seed-observability.test.mjs \
test/e2e/nested-layout-partial-swap.test.mjs test/e2e/form-submission-and-race.test.mjs
node scripts/run-bun-tests.js
( cd gallery && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd examples/blog && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd website && npx webjs elision --verify && npx webjs check && npx webjs doctor )
webjs-core-browser.js should not move at all in this phase, since render-server.js is excluded from the browser entry by design (index-browser.js:5). A non-zero delta there means the split leaked a server module into the browser bundle, which is a hard fail, not a tolerance question.
Phase 9. packages/server/src/dev.js (3070 LOC)
Barrel packages/server/src/dev.js. Modules in packages/server/src/dev/.
Largest blast radius in the sequence: 62 test files import it by relative path, it is the boot path of every app, and it is runtime-sensitive on Bun. It goes last so every mechanic in this plan is already proven.
This is the one file whose main function must be decomposed rather than moved.createRequestHandler spans lines 536-1682, which is 1147 lines in one closure. It already funnels its mutable data through a state object (state.routeTable, state.lastDevError, state.regenerateRules) and already hands a ctx record to startNodeListener, so the pattern is half present. Finish it: build one explicit boot record plus the existing state, and pass both to the extracted phase functions instead of capturing them.
Barrel re-exports the 16 current exports. The internal symbols the tests import are createRequestHandler, startServer, readDevWatchPathsFromApp, shouldIgnoreWatchPath, readElideEnabled, readSeedEnabled, all covered.
Correction to the issue's proposed list. The issue proposed config-reader.js, http-server.js, watcher.js, hmr.js, error-overlay.js, route-dispatcher.js. Four of those map onto real seams and are kept under different names. hmr.js does not exist as a concept in this file: WebJs has no HMR, it has a full-reload SSE channel, and the code for it is the reload-client.js generator plus the SSE hub in listener-core.js, which is already a separate module. error-overlay.js is likewise already separate (packages/server/src/dev-error.js builds the frame, dev-overlay.js is the client script), and what lives in dev.js is only reportDevError, which is 35 lines and belongs with readiness. The six missing concerns the list above adds are the boot sequence, the readiness and rebuild machinery, the internal /__webjs/* route table, the node listener shell, the TypeScript-stripping static serve, and the app analysis helpers.
The naming caveat, recorded.dev/ is inherited mechanically from the barrel's basename per D2, and it is a poor description of the contents, since start-server.js and app-config.js run in production. Put a banner comment saying so at the top of packages/server/src/dev.js. Renaming to src/server/ would cost 62 test import sites for zero behaviour.
Also in this PR. Widen the Bun parity hook regex from /dev\.js to /dev[./] per D10.
The three invariants to preserve.
Ordering inside createRequestHandler is load-bearing and documented in the comments. loadAppEnv must run before applyEnvValidation, which must run before runInstrumentation, which must run before the route table and action index are built, and the 'use server' load hook must install before any action module is imported (ESM caches by URL). Extracting the boot sequence must preserve that exact order, and the extracted function must document it.
setBasePath runs before setCoreInstall and setVendorEntries, which run before setAssetRoots, so the published build id is a stable deploy fingerprint. Same constraint, different chain.
startServer picks the listener shell by runtime (serverRuntime() === 'bun' dynamically imports listener-bun.js, otherwise startNodeListener). Do not move the dynamic import to a static one, or the Bun.* global is referenced on Node.
Verification for phase 9.
node scripts/find-cycles.mjs packages/server/src
npm test
node --test packages/server/test/**/*.test.js
node --test packages/cli/test/**/*.test.js
npm run test:browser
WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs test/e2e/dev-seed-observability.test.mjs \
test/e2e/dev-overlay-nav.test.mjs test/e2e/browser-harness.test.mjs \
test/e2e/nested-layout-partial-swap.test.mjs test/e2e/form-submission-and-race.test.mjs
node scripts/run-bun-tests.js
( cd gallery && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd examples/blog && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd website && npx webjs elision --verify && npx webjs check && npx webjs doctor )
( cd gallery && npm run dev ) # boot smoke, then Ctrl-C
( cd examples/blog && npm start ) # prod boot smoke, then Ctrl-C
Tests
This refactor is behaviour-preserving, so the usual "add a test that fails when reverted" rule inverts. The existing suites ARE the counterfactual. A behaviour-preserving move that breaks something breaks an existing test; a move that breaks nothing needs no new assertion of its own. What the split DOES introduce as a new failure mode is a silently dropped export, and that is covered by the one new test in D9 plus the per-phase export diff.
The one new test.test/architecture/barrel-surface.test.mjs, added in phase 1, holding the ten export-count floors from D9. It imports each barrel and asserts Object.keys(mod).filter(k => k !== 'default').length >= floor. Rationale and the anti-rot argument are in D9.
Per-phase layer applicability.
Phase
Unit
Browser
e2e
Bun
Smoke and apps
Elision verify
1 doctor
yes
no
no
no
webjs doctor in 3 apps
no
2 vendor
yes
no
no
no
webjs doctor in 3 apps
no
3 check
yes
no
no
no
webjs check in 3 apps
no
4 router-client
yes
yes
yes
yes
3 apps
yes
5 render-client
yes
yes
yes
yes
3 apps
yes
6 slot
yes
yes
yes
yes
3 apps
yes
7 component
yes
yes
yes
yes
3 apps
yes
8 render-server + ssr
yes
yes
yes
yes
3 apps
yes
9 dev
yes
yes
yes
yes
3 apps plus a boot smoke
yes
Why the first three skip four layers.lib/doctor.js, src/vendor.js, and src/check.js never reach a browser, never run on a request path, and none of their paths matches the Bun parity hook's runtime-sensitive regex. webjs elision --verify has nothing to compare, since none of them participates in SSR. Running those layers anyway costs about forty minutes per phase for zero signal. This is a decision, not an omission.
Bun parity is mandatory for phases 4 through 9 and is enforced, not merely expected: .claude/hooks/require-bun-parity-with-runtime-src.sh BLOCKS the commit when a runtime-sensitive path is staged with no test/bun/** file alongside. The escape hatch is WEBJS_BUN_VERIFIED=1 and it is not the honest answer here, because these are exactly the surfaces the gate exists for. The test/bun/** files already in play, none of which needs a new sibling since the behaviour does not change.
The whole set runs with node scripts/run-bun-tests.js. Satisfy the hook by touching the relevant test/bun/** file with the phase's assertion re-run, or by staging a comment recording which Bun tests were run and their result. Do not reach for the escape hatch.
Two known-flaky-in-a-worktree results, so they are not chased. Five tests fail in a worktree:link worktree and pass in the primary checkout and in CI (the listener and listener-overhead pair plus three elision assertions). If exactly those five fail, it is the worktree, not the phase. Confirm by re-running them in a worktree cut from origin/main with no changes.
Rebuild dist before e2e and Bun, every core phase.node scripts/build-framework-dist.js. Skipping it makes the whole e2e and Bun run vacuous, since both resolve the built bundle rather than src.
Docs
Every path below was checked at HEAD 509d8809. Where nothing changes, that is stated so the implementer does not go looking.
Real doc surfaces that MUST change.
Path
Phase
What changes
AGENTS.md:162 ("Framework source: where to find it")
3, 4, 8
It names four starting points by exact path (@webjsdev/server/src/ssr.js, @webjsdev/core/src/render-client.js, @webjsdev/core/src/router-client.js, @webjsdev/server/src/check.js). All four still RESOLVE after the split, since the barrel keeps the path. Add one sentence saying each is now a barrel over a sibling directory, so an agent reading it knows to look one level down
packages/core/AGENTS.md:84 and :274
4
Both name src/router-client.js as the home of the 63 _ seams. The seams stay on the barrel, so the claim stays true. Add the directory pointer
packages/core/AGENTS.md:113
8
Names packages/server/src/ssr.js as what the metadata surface is derived from. Point at ssr/metadata.js
packages/server/AGENTS.md
9
Holds the canonical webjs.* config-reader inventory, all of which moves to dev/app-config.js. Update the paths in place
packages/cli/AGENTS.md:193
1
Names readDoctorPolicy and applyDoctorPolicy as living in lib/doctor.js. They move to lib/doctor/policy.js and are re-exported. Update the sentence
framework-dev.md:176
4
Names src/router-client.js as the home of the 63 _ seams for the .d.ts guard exemption. Still true, add the directory pointer
framework-dev.md:204
2
Names packages/server/src/vendor.js as the file whose fetch callers all catch, which is why the JSPM double records refusals. Point at vendor/jspm.js
framework-dev.md:244
9
Names packages/server/src/dev.js as the file that statically imports core symbols, which forces the core-publishes-first order. Point at dev/handler/boot.js
framework-dev.md:259
9
Names reportDevError plus the SSE push in packages/server/src/dev.js. Point at dev/handler/readiness.js
packages/mcp/src/mcp.js:123 and :160, packages/mcp/src/mcp-source.js:16 and :212
8
These use server/src/ssr.js as an EXAMPLE path in a tool description and an error message. The source tool reads any path under the @webjsdev/* src trees behind a traversal guard, so it keeps working with no code change and a nested path is readable. Refresh the example strings so an agent following them lands on something informative
The regex widenings from D10. Not a doc surface, but it ships in the same commits
Surfaces that do NOT change, checked.
The docs site (website/app/docs/**) documents the framework's public API, not its file layout. The one exception is website/app/docs/no-build/page.ts:51, which prints the literal "@webjsdev/core/client-router": "/__webjs/core/src/router-client.js" as an importmap example. Because D2 keeps the path, that string stays correct. This is the single strongest reason the rename was rejected.
website/app/why-webjs/page.ts:150-151 prints core/src/render-server.js: export async function renderToString( and server/src/ssr.js: const html = await renderToString(tree) as illustration. Both stay correct, since both barrels keep their path and renderToString stays exported from render-server.js.
.agents/skills/webjs/** names exactly one framework source path, node_modules/@webjsdev/core/src/component.js in references/components.md:417, as a place to grep the base surface. The barrel keeps that path resolvable. Add "and its sibling component/ directory" in phase 7 so the grep advice stays useful.
The scaffold templates (packages/cli/templates/**) name no framework source path. Nothing to sync, so the webjs-scaffold-sync skill does not apply to any phase.
README.md documents capabilities, not file layout. No change.
CONVENTIONS.md covers app conventions. No change.
The doc gate..claude/hooks/require-docs-with-src.sh BLOCKS a commit that stages packages/*/src or packages/cli/lib with no doc surface alongside. Every phase in this plan stages a real doc surface (the table above assigns at least one to each of phases 1, 2, 3, 4, 8, 9, and phases 5, 6, 7 stage packages/core/AGENTS.md and .agents/skills/webjs/references/components.md). So WEBJS_NO_DOC_GATE=1 is NOT the honest answer for any phase and must not be used. A pure internal move that genuinely changed no documented surface would be the case for it, and this refactor is not that: the "Framework source" sections in AGENTS.md, framework-dev.md, and all three per-package AGENTS.md files exist specifically to tell a cold agent where to look, and this refactor changes where to look. Leaving them stale reproduces the #488 gap the hook was written for.
Invoke the webjs-doc-sync skill on each phase to confirm no surface was missed.
Acceptance criteria
Nine PRs merged, each closing this issue, in the order given in D1, each rebased on origin/main immediately before opening.
All ten monoliths are barrels over sibling directories, keeping their current paths per D2. No package.jsonexports, files, or types entry changed in any of the three packages.
LOC criterion, in its settled form. The command below prints nothing. sh find packages/core/src/router-client packages/core/src/render-client packages/core/src/render-server \ packages/core/src/component packages/core/src/slot packages/server/src/dev \ packages/server/src/vendor packages/server/src/ssr packages/server/src/check packages/cli/lib/doctor \ -name '*.js' -exec wc -l {} + | awk '$1 > 1000 && $2 != "total"'
It binds only modules produced by this issue. Barrels are exempt. Any exemption beyond packages/core/src/component/base.js must be named in the merging PR with its measured size and reason. No CI guard is added (see D3).
Every barrel's runtime export set is byte-identical to its pre-split module's, proved per phase by the D9 export diff with empty missing and empty added, pasted in each PR body.
test/architecture/barrel-surface.test.mjs exists with all ten floors and passes.
node find-cycles.mjs packages/core/src packages/server/src packages/cli/lib reports zero cycles after every phase, as it does at HEAD.
node --input-type=module -e "await import('./packages/core/index.js')" and the same for index-browser.js both load cleanly after every core phase.
packages/core/dist/webjs-core-browser.js grew by no more than 1.0% in any single core phase and no more than 2.0% cumulatively from its 137,038-byte baseline. Sizes recorded before and after in each PR body.
packages/core/dist/webjs-core.js did not grow at all in phase 8, since render-server.js is excluded from the browser entry and its split must not leak anywhere.
npm test, npm run test:browser, the six e2e files under WEBJS_E2E=1, and node scripts/run-bun-tests.js are green on the phases the Tests table marks, with no new skips and no WEBJS_BUN_VERIFIED=1.
npx webjs elision --verify reports parity in gallery, examples/blog, and website on phases 4 through 9, with a non-zero compared count in each (a zero-compared run is a vacuous pass and fails this criterion).
npx webjs check reports zero violations and npx webjs doctor exits zero in all three apps, on every phase.
npx webjs check --rules output is byte-identical to origin/main after phase 3.
.claude/hooks/require-bun-parity-with-runtime-src.sh matches every new path under packages/server/src/ssr/ and packages/server/src/dev/, per D10, verified by staging one file from each directory and confirming the hook fires.
Every doc surface in the Docs table is updated in the phase it is assigned to. WEBJS_NO_DOC_GATE=1 appears in no commit.
No behaviour changed. Any behaviour difference found mid-refactor is reported in the PR body and NOT fixed inside the move commit.
Out of scope
Behaviour-preserving move only. The implementer must not widen into any of the following.
No bug fixes. If a split surfaces a real defect, report it in the PR body and leave the code as it was. Fixing it inside a move commit destroys the one property that makes this refactor reviewable, that the diff is a pure relocation.
No API changes. No renamed export, no changed signature, no new option, no removed dead export. added in the D9 export diff must be empty, which mechanically forbids this.
No new features. In particular no bundler, no webjs build, no dev-mode bundling to claw back the dev-only request count from D6. Those are deliberately deferred per AGENTS.md.
No back-compat shims beyond the barrels. No deprecated aliases, no old-path stubs, no re-export of a renamed name. WebJs has no users, so a shim is pure cost. Each barrel is justified by the import sites and the published exports entries measured in D2, not by compatibility.
No reformatting of untouched code. Move a function with its comments byte-identical. A whitespace or comment-rewrap pass inside a moved block makes the relocation unreviewable. Prettier or any formatter run across a moved file is a hard no.
No splitting of files outside the ten. Specifically NOT packages/core/src/html-entities.js (2179, a generated data table), packages/cli/bin/webjs.js (1665), packages/cli/lib/create.js (1636), packages/editors/intellisense/src/index.js (1493), packages/server/src/component-elision.js (1374), packages/core/src/form-action.js (1258). They are over 800 lines and they are not this issue's business.
No LOC CI guard. Reasoned and rejected in D3.
No renaming packages/server/src/dev/ to something more accurate. The caveat is recorded in P9 and the 62 import sites are the reason.
No follow-up issues. Report findings in the PR body. Fold a small tweak in a file the PR already touches into the PR. Anything genuinely separate goes here, in this section, in the PR that found it.
No merging without approval. Each phase asks, and waits for both answers.
Staleness discipline. Every line anchor in this plan is dated to HEAD 509d8809. This issue's file surface overlaps essentially every other in-flight change in the repo, so anchors age fast. Each phase MUST git fetch origin and cut its worktree from origin/main immediately before starting, and MUST re-derive its ranges with grep -n rather than trusting the numbers here. A phase that opens more than a few days after the previous one merged should re-run the structural greps in full before writing a line.
Problem
Ten source files across
@webjsdev/core,@webjsdev/server, and@webjsdev/clihave grown into single-file monoliths. Every figure below was re-measured withwc -lat HEAD509d8809and every one in the original body was correct.packages/core/src/router-client.jspackages/server/src/dev.jspackages/core/src/render-client.jspackages/server/src/vendor.jspackages/core/src/render-server.jspackages/server/src/ssr.jspackages/core/src/slot.jspackages/core/src/component.jsWebComponentbase class, lifecycle, reflection, server element shimpackages/server/src/check.jswebjs checkrule engine and all 18 rules inline in one functionpackages/cli/lib/doctor.jsTotal 26,329 lines. Runtime export counts, measured by importing each module at HEAD, are the contract any split has to preserve.
_test seamsrouter-client.jsslot.jsvendor.jsssr.jsdev.jsdoctor.jsrender-server.jscomponent.jscheck.jsrender-client.jsThe largest hidden cost is the relative-path import surface. Counting
from '<path>/<file>.js'sites acrosspackages/*/test,test/,packages/*/src, and the in-repo apps gives 214 import sites for the ten files. Per file, counting distinct TEST files only.dev.jscomponent.jsrouter-client.jsrender-client.jsrender-server.jscheck.jsvendor.jsslot.jsssr.jsdoctor.jsMany of those import INTERNAL symbols, not the public API. The full internal-symbol contract, extracted mechanically from the import clauses, is recorded per phase below.
What was stale or wrong in the previous body
packages/core/src/html-entities.js2179,packages/cli/bin/webjs.js1665,packages/cli/lib/create.js1636,packages/editors/intellisense/src/index.js1493,packages/server/src/component-elision.js1374,packages/core/src/form-action.js1258. The criterion is restated in a mechanically checkable form under Design.src/client-router.jsis superseded. It contradictspackages/core/package.json, whose./client-routersubpath maps"source": "./src/router-client.js"and"types": "./src/router-client.d.ts". Reasoning under Design.src/dev/is a misleading directory name for whatdev.jsholds, becausestartServer(line 1729) is the PRODUCTION entry too and the 16read*FromAppconfig readers (lines 181-535) run in both modes. The name is kept anyway for a stated mechanical reason, with the caveat recorded.Design / approach
D1. This ships as nine independently mergeable PRs, all closing this issue
A ten-file, 26,000-line refactor in one PR is unreviewable and unbisectable. If any one of the ten splits regresses SSR bytes or drops an export, a single PR gives no way to bisect which. One subsystem per PR is the norm for this shape and is what every cited prior art did incrementally.
Every PR body carries
Closes #1365, so the last one to merge closes it. Do NOT file follow-up issues for anything found on the way. Report it in the PR body and leave it.Order, by ascending blast radius, with each phase establishing mechanics the next one reuses.
lib/doctor.jssrc/vendor.jssrc/check.jssrc/router-client.jspackages/core/srcimports it except the two index entries), but 32 test files, 63_seams, and the first phase with browser-bundle exposuresrc/render-client.jssrc/slot.jsrender-client.js,component.js, androuter-client.js. Goes after both consumers are split so its import surface is already stablesrc/component.jssrc/render-server.js+src/ssr.jssrc/dev.jsPhases 8 and 9 are the only pair that must move together, and they are 8.
packages/server/src/ssr.js:3importsrenderToStringfrom the@webjsdev/corepackage specifier, not from a relative path, so the two are not coupled at module level. They are coupled through the bytes:ssr.jsdrivesrenderToString/renderToStreamand then post-processes the HTML those produce (DSD injection,<webjs-suspense>processing, head hoisting, boundary markers). One PR gives one SSR-bytes verification pass over both halves instead of two passes that each leave the other half unproven.D2. Naming principle, settled once for all ten
The monolith's path stays and becomes the barrel. New modules live in a sibling directory named after the barrel's basename. So
packages/core/src/router-client.jsremains, and its parts land inpackages/core/src/router-client/.Alternative REJECTED: rename to the public-export name (
src/client-router.js), which is what the single existing comment on this issue proposed. Rejected on measured cost.packages/core/package.jsonmaps./client-routerto"source": "./src/router-client.js"and"types": "./src/router-client.d.ts". A rename changes the published manifest for zero behaviour..d.tsoverlays inpackages/core/src/, and BOTH type guards derive the runtime sibling from the overlay path by the sibling rule (foo.d.tsoverlaysfoo.js), stated in the header oftest/types/dts-export-coverage.test.mjsand again intest/types/dts-no-phantom-exports.test.mjs. Renaming the.jswithout the.d.tsbreaks that derivation silently.packages/core/test/routing/client-router-export-parity.test.jsimports'../../src/router-client.js'AND readssrc/router-client.d.tsoff disk by name.website/app/docs/no-build/page.ts:51prints the literal string"@webjsdev/core/client-router": "/__webjs/core/src/router-client.js"as a docs example of the emitted importmap.packages/core/AGENTS.mdnamessrc/router-client.jsat lines 84 and 274, andframework-dev.md:176names it as the home of the 63_seams.Keeping the path makes every phase's diff purely additive at the import layer. No existing import,
.d.ts, manifest entry, docs sample, or type guard has to change.The barrels are not compatibility shims. WebJs has no users, so a shim would be theatre. Each barrel is justified by the import sites it keeps working inside this repo (62, 36, 32, 22, 12, 11, 6, 5, 3, 2 test files respectively) and by the published
exportsmanifest, which names four of the ten paths directly. Beyond the barrel, ship NO compatibility layer: no deprecated aliases, no old-path stubs, no re-export of a name the split renamed.packages/core/package.jsonfilesneeds no change. It lists"src"wholesale, so a new subdirectory publishes automatically. Same forpackages/server("src") andpackages/cli("lib"). Verified against all three manifests.One caveat, recorded rather than fixed.
packages/server/src/dev/reads as dev-only and is not, sincestartServerand the 16 boot config readers live there and run in production. Renaming tosrc/server/would cost 62 test import sites and apackage.json-adjacent rename for zero behaviour, so the mechanical rule wins. Say so in a banner comment at the top of the barrel.D3. The 800-LOC criterion, settled
Scope. The criterion binds ONLY the ten files this issue splits and the modules produced from them. No other file in the repo is bound by it, and none of them is in scope.
Rule. A produced module targets 800 lines and MUST NOT exceed 1000. A barrel is exempt (its length is a function of its export count, and
router-client.jsalone re-exports 68 names). A module that would need an artificial seam to get under 1000 is exempt only if it is named in this plan with its measured size and its reason. Exactly one such exemption is anticipated,packages/core/src/component/base.js, and P7 states the fallback that avoids it.Mechanically checkable form, run once at the end of the sequence.
find packages/core/src/router-client packages/core/src/render-client packages/core/src/render-server \ packages/core/src/component packages/core/src/slot packages/server/src/dev \ packages/server/src/vendor packages/server/src/ssr packages/server/src/check packages/cli/lib/doctor \ -name '*.js' -exec wc -l {} + | awk '$1 > 1000 && $2 != "total"'Empty output passes.
Why not a hard 800, and why no CI guard. Every framework this issue cites as its model exceeds 800 in its own core modules, measured in the local clones:
lit/packages/reactive-element/src/reactive-element.tsis 1754,lit/packages/lit-html/src/lit-html.tsis 2303,vite/packages/vite/src/node/server/index.tsis 1447,vite/packages/vite/src/node/optimizer/index.tsis 1487. Those projects draw their seams by responsibility, not by line count. A LOC gate in CI is a proxy metric that fights cohesion: it would red onpackages/core/src/html-entities.jsforever, and it would have to encode an exemption list that rots. Do not add one. The criterion is a one-time acceptance check, verified by the command above.Data tables are exempt by nature.
packages/core/src/html-entities.js(2179 lines) is the WHATWG named-character-reference table plus the legacy semicolon-less names. It has no seams, splitting it changes no byte, and it is not in scope.D4. Cycle and TDZ discipline
Splitting a file whose functions call each other in both directions produces ESM cycles. Node tolerates a cycle, but reading a
constorclassbinding during the cycle's evaluation phase throws a TDZReferenceErrorat module load, and in the minified browser bundle that surfaces as a blank page rather than a test failure.Baseline, measured. There are currently zero import cycles across all 119 modules in
packages/core/src,packages/server/src, andpackages/cli/lib. So the invariant is "still zero", which is checkable and currently true.Discipline.
const X = other.thingor aclass A extends other.Bat module scope is the shape that throws.render-client.jsapply and instance group is the one real case), introduce an explicit dispatch registry module that both sides import, with the implementations registered at module load. P5 specifies it.Per-phase check. Write this to your scratchpad and run it after each phase. It takes no dependency and writes nothing into the repo.
Load smoke, per phase. Both core browser entries load cleanly under Node today (verified), so a TDZ throw introduced by a split shows up immediately.
The MINIFIED-bundle half of the risk is covered by
npm run test:browser, which boots the built bundle in real Chromium. That is why the browser suite is mandatory on every core phase even though the change is behaviour-preserving.D5. The published
dist/bundle is a hard gatescripts/build-framework-dist.jsbundles four entries (index.js,index-browser.js,src/lazy-loader.js,src/testing.js) with esbuild atbundle: true,splitting: false,treeShaking: true,minify: true, plus its own sanity check that every entry produced its expected output filename.Three consequences the implementer must encode.
(a) e2e and Bun tests resolve the BUILT bundle. A src-only edit is invisible to them until
distis rebuilt, so a counterfactual passes vacuously. Every core phase rebuilds first.(b) Bundle size is a gate with a stated tolerance. Record
webjs-core-browser.jsbyte size before and after each core phase.Baseline at HEAD
509d8809is 137,038 bytes forwebjs-core-browser.jsand 189,088 forwebjs-core.js.Tolerance is +1.0% per core phase and +2.0% cumulative across phases 4 through 8. Reasoning: a pure module split should be byte-neutral after bundling and minification, since the statements are identical and only their file boundaries moved. The two legitimate sources of drift are esbuild disambiguating an internal identifier that now collides across modules, and code that esbuild previously dropped inside one module but can no longer drop once it crosses a module boundary as an export. The second is exactly the regression worth catching, so the tolerance has to be tight enough to surface it. One percent of 137 KB is about 1.4 KB, roughly one medium function, which is tight enough to catch a defeated tree-shake and loose enough not to red on identifier renaming. A phase over tolerance must explain the delta in its PR body before merging.
(c) The build script's own sanity check must still pass. It throws when an entry name goes missing. Since no entry path changes in this plan, a failure here means a barrel stopped resolving.
D6. Dev-time serving needs no change (investigated, evidence recorded)
In dev with no built
dist, the browser fetches core source files individually rather than the bundle. Do not go hunting; this was resolved.packages/server/src/importmap.jsmaps the prefix'@webjsdev/core/'to'/__webjs/core/src/'(lines 306 and 397). It is a PREFIX mapping, not a file enumeration.packages/server/src/dev.js:2011serves/__webjs/core/*by resolving the remainder against the resolved core directory behind a trailing-separator traversal guard. A nested path such as/__webjs/core/src/router-client/state.jsis served with no code change.modulepreloadhint is emitted for core (packages/server/src/ssr.js:1718-1729, the bare@webjsdev/coreimportmap target), not one per file, so nothing enumerates core's file list.The one real dev-only cost. Splitting a core file into N modules adds N requests at one extra graph depth on a dev page load, because ES module resolution is a waterfall by depth. This is localhost, dev-only, and disappears in production, where
distmode serves one bundle. It is accepted, not mitigated. Do not add a dev-mode bundler.D7. Elision is both a risk surface and the best verification tool
packages/server/src/component-elision.jsandpackages/server/src/js-scan.jsdecide what ships to the browser. Checked, and the finding is narrow: neither matches on any core source filename. The single occurrence ofcomponent.jsincomponent-elision.jsis at line 35 inside a prose doc comment, andjs-scan.jscontains no reference to a core src filename at all. The analysers work on the APP's module graph and on@webjsdev/coreas a package specifier, which no phase changes.Elision is therefore used here as the verification tool it is.
npx webjs elision --verifyrenders every static page route with elision on and off and diffs the observable SSR bytes. Run it in all three in-repo apps on every phase that touches SSR, rendering, or the client bundle (phases 4 through 9).What it does NOT cover. It proves the bytes you SERVE did not change. It says nothing about post-hydration behaviour, because a wrongly dropped module is a dead click, not different bytes. That gap is why
npm run test:browserand the e2e suite still have to run on every core phase.D8. Prior art, verified against the clones and corrected
Clones live at
~/Documents/Projects/frameworks/. Every claim below was read, not assumed.turbo/src/core/drive/splits intovisit.js,navigator.js,page_renderer.js,snapshot_cache.js,form_submission.js,head_snapshot.jsprefetch_cache.js,preloader.js,view_transitioner.js,progress_bar.js,history.js,page_view.js,page_snapshot.js,error_renderer.js,morphing_page_renderer.js,limited_set.js, which map one-to-one onto WebJs router concerns the old table omitted (prefetch, view transitions, nav progress, DOM morphing). Largest module isvisit.jsat 419 lines; the median is under 150. This is the closest match to the P4 targetrouter-reducer/splits intonavigate-reducer.ts,restore-reducer.ts,server-action-reducer.tsnext.js/, notnext/, and the three files live underrouter-reducer/reducers/, alongsiderefresh-reducer.ts,server-patch-reducer.ts,hmr-refresh-reducer.ts,find-head-in-cache.ts,committed-state.ts.navigate-reducer.tsis 56 lines androuter-reducer.tsis 69. The seam is a pure state-transition dispatch, which WebJs has no equivalent of (its router mutates the DOM directly), so this informs thestate.jsandnavigator.jsseam in P4 and nothing elsereactive-element(lifecycle, props, controllers) fromlit-html(templates, parts) anddirectives/lit/packages/reactive-element/src/holdsreactive-element.ts(1754 lines),reactive-controller.ts,css-tag.ts,decorators/.lit/packages/lit-html/src/holdslit-html.ts(2303 lines),directive.ts,async-directive.ts,directive-helpers.ts,directives/. Lit draws the seam by RESPONSIBILITY across packages and leaves each core module large. This is the direct evidence behind D3server/is modular (index.ts,hmr.ts,ws.ts,moduleGraph.ts,transformRequest.ts,send.ts,middlewares/)environments/,pluginContainer.ts,warmup.ts,sourcemap.ts.index.tsis 1447 lines andhmr.ts1191, so again the orchestration entry stays large while the concerns split out. That is precisely the shape P9 targetsvite/packages/vite/src/node/optimizer/holdsindex.ts(1487),optimizer.ts(808),scan.ts(797),resolve.ts,pluginConverter.ts,rolldownDepPlugin.ts. Scan and resolution are separate as claimed. There is no importmap generation in Vite's optimizer at all (Vite rewrites specifiers at transform time), and cache storage is not its own module, it lives insideindex.tsandoptimizer.ts. The corrected lesson for P2 is narrower: split the SCANNER from the RESOLVER, and expect the orchestration entry to stay the largest module@remix-run/server-runtimeseparates "layout matching, boundary stream construction, and head metadata extraction"remix-v2/packages/remix-server-runtime/holdsrouteMatching.ts(29 lines),routeModules.ts(268),links.ts(192),entry.ts(58),markup.ts(19),serverHandoff.ts(30), withserver.tsat 813 as the orchestrator. Matching and head metadata are separate as claimed. Boundary stream construction is not in that package, it lives inremix-v2/packages/remix-react/components.tsx(1356 lines) andserver.tsx. The corrected lesson for P8 is that tiny single-purpose modules (routeMatching,markup,serverHandoff) around a large orchestrator is the achievable shape, not an evenly-sized fanD9. The export contract, and the one new test
The failure mode this refactor introduces is a silently dropped export. Three guards already exist and cover most of it.
test/types/dts-no-phantom-exports.test.mjsproves every value an overlay declares EXISTS at runtime, per publishedexportssubpath. A dropped PUBLIC export fails here by name.test/types/dts-export-coverage.test.mjsproves the reverse per subpath.packages/core/test/routing/client-router-export-parity.test.jsderives the public router surface fromsrc/router-client.d.tsand asserts it on the module, onindex.js, and onindex-browser.js._seam is guarded implicitly, because the test importing it fails at LINK time with "does not provide an export named" when it disappears.What none of them catches is a barrel that quietly carries FEWER seams than before while every currently-written test still happens to pass. Add one test,
test/architecture/barrel-surface.test.mjs, holding a per-barrel export-COUNT floor. A count floor is not a name list, so it does not rot as exports are added, and it is the same anti-vacuum devicedts-export-coverage.test.mjsalready uses for itsminNamesfloors. Floors are the counts measured at HEAD.packages/core/src/router-client.jspackages/core/src/slot.jspackages/server/src/vendor.jspackages/server/src/ssr.jspackages/server/src/dev.jspackages/cli/lib/doctor.jspackages/core/src/render-server.jspackages/core/src/component.jspackages/server/src/check.jspackages/core/src/render-client.jsThe test is added in phase 1 with all ten entries live from the start, since all ten modules exist at HEAD and the floors hold before any split. That way every later phase inherits the guard rather than adding it.
Per-phase mechanical proof, in addition to the test. Before opening each PR, diff the barrel's export list against the pre-split module's and paste the (empty) diff into the PR body.
missingmust be empty.addedmust be empty too, because a behaviour-preserving split adds no public surface.D10. Two enforcement hooks need edits, one does not
.claude/hooks/require-bun-parity-with-runtime-src.shdecides that a staged path is runtime-sensitive by matching this regex against the path.packages/server/src/dev/app-config.jsmatches none of those alternatives, and neither doespackages/server/src/ssr/head.js. Splitting either file would silently disable the Bun parity gate for the resulting modules. Fix it in the phase that causes it, by widening the two exact-file alternatives so they also match a directory./ssr\.jsto/ssr[./]./dev\.jsto/dev[./].Neither widening captures an unrelated sibling (
dev-error.jshas a-afterdev, not a.or/).render-serveralready matches without anchors, sopackages/core/src/render-server/dsd.jsstays covered with no edit..claude/hooks/require-docs-with-src.shmatches^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, which already matches every nested path. No edit..claude/hooks/require-tests-with-src.shis satisfied by every phase, since each one touches test files.Implementation plan
Before every phase, without exception.
The line anchors in this plan are dated to HEAD
509d8809. Re-derive every range withgrep -nin your worktree before editing. A phase that lands weeks after this was written will find them moved.Push the branch and open a DRAFT PR immediately, with
Closes #1365in the body. Mark it ready only after the phase's full verification set is green and pasted.Phase 1.
packages/cli/lib/doctor.js(1686 LOC)Barrel
packages/cli/lib/doctor.js. Modules inpackages/cli/lib/doctor/.Current internal structure and the seams it dictates.
DOCTOR_SEVERITIES,DOCTOR_CODES,codeForNamedoctor/codes.jsisPlainObject,readDoctorPolicy,applyDoctorPolicydoctor/policy.jsreadEngines,stripJsonc,parseEnvKeys,newestMtimeMs,isCommentedOutdoctor/util.jssatisfiesRange,makeInstalledManifestReader,formatConflicts,readInstalledVersiondoctor/manifest.jsROUTE_WALK_IGNORE,ROUTE_MODULE_RE,ROOT_ONLY_MODULE_RE,readAppBasePath,collectRouteModulesdoctor/route-modules.jscheckNodedoctor/probes/node.jscheckTsconfigdoctor/probes/tsconfig.jscheckEnvdoctor/probes/env.jscheckVendorPindoctor/probes/vendor-pin.jscheckVendorGitignoredoctor/probes/vendor-gitignore.jscheckImportmapCoherencedoctor/probes/importmap-coherence.jscheckWebjsVersionsdoctor/probes/webjs-versions.jscheckGitHookdoctor/probes/git-hook.jscheckElisionCarriers,checkElisionComponentsdoctor/probes/elision.jsFRESHNESS_IGNORE,checkStaticAssetFreshnessdoctor/probes/static-asset-freshness.jsLINK_TAG_RE,ATTR_RE,parseTagAttrs,unmarkedStylesheetHref,checkUnmarkedAssetLinksdoctor/probes/unmarked-asset-links.jsframeworkResolves,checkFrameworkResolvesdoctor/probes/framework-resolves.jsrunDoctorChecksdoctor/runner.jsBarrel re-exports the 9 current exports:
DOCTOR_SEVERITIES,DOCTOR_CODES,codeForName,readDoctorPolicy,applyDoctorPolicy,readAppBasePath,frameworkResolves,checkFrameworkResolves,runDoctorChecks.Layering, bottom to top:
codes.jsandutil.jsare leaves;policy.js,manifest.js,route-modules.jssit above them; everyprobes/*.jsimports downward only;runner.jsimports the probes andcodes.js; the barrel imports everything.The one non-move step.
runDoctorCheckscurrently attachesr.code = codeForName(r.name)after thePromise.all. Keep that inrunner.jsexactly as is. Do NOT move code assignment into the probes, which would be a behaviour change disguised as a refactor.The internal symbol the tests import is
readAppBasePathonly (packages/cli/test/), so the barrel's contract is small.packages/cli/bin/webjs.js:431and:782dynamically import'../lib/doctor.js', which the barrel keeps working unchanged.Also in this PR: add
test/architecture/barrel-surface.test.mjswith all ten floors from D9.Verification for phase 1.
Browser, e2e, Bun, and elision do NOT apply.
lib/doctor.jsnever reaches a browser, never runs on a request path, and is not on the Bun parity hook's runtime-sensitive list.Phase 2.
packages/server/src/vendor.js(2430 LOC)Barrel
packages/server/src/vendor.js. Modules inpackages/server/src/vendor/.BUILTIN,FRAMEWORK_SERVER_ONLY,scanBareImports,extractPackageName,IMPORT_RE,DYNAMIC_IMPORT_RE,stripComments,isServerOnlyFile,CONFIG_FILE_RE,walkvendor/scan.jsresolvePackageDir,getPackageVersion,getPackageManifestvendor/manifest.jsjspmCache,lastLiveResolveFailed, the endpoint and timeout constants,SUPPORTED_PROVIDERS,normalizeProvider,jspmCall,jspmResolveOne,jspmProbeOne,jspmGenerate,mergePerInstall,vendorImportMapEntries,clearVendorCachevendor/jspm.jsderivePinParts,PIN_DIR_REL,PIN_FILE,pinDir,pinFilePath,VENDOR_GITIGNORE_LINES,vendorPinIsIgnored,ensureVendorCommittable,hasVendorPin,bundleFilenameWithSubpath,sha384Integrity,readPinFile,writePinFilevendor/pin-file.jsdownloadBundle,fetchIntegrity,pruneOrphansvendor/download.jspinAll,unpinPackage,listPinnedvendor/pin-commands.jsNPM_REGISTRY,NPM_TIMEOUT_MS,fetchNpmJson,groupPinnedByPackage,auditPinned,findOutdated,updatePinnedvendor/registry.jscompareSemver,maxSemverVersion,basePackage,satisfiesSemverRange,parseSemver,cmpSemvervendor/semver.jsprunePinToReachable,extractPinnedVersionsvendor/pin-prune.jscheckImportmapCoherence,liveIntegrityCache,INTEGRITY_FETCH_CONCURRENCY,INTEGRITY_TOTAL_BUDGET_MS,fetchLiveIntegrity,computeLiveIntegrityvendor/coherence.jsresolveVendorImports,serveDownloadedBundlevendor/serve.jsCorrection to the issue's proposed list. The issue proposed
resolver.js,jspm.js,importmap-builder.js,disk-cache.js. There is no importmap BUILDER in this file (packages/server/src/importmap.jsis a separate module that builds the map;vendor.jsonly produces the vendor ENTRIES that feed it, invendorImportMapEntries). And "disk cache" is really two unrelated things, the pin FILE at.webjs/vendor/importmap.jsonand the downloaded BUNDLE files, which have different lifecycles. The list above replaces both names. This matches the corrected Vite lesson in D8, where the real seam is scanner-versus-resolver and the orchestration entry stays largest.Barrel re-exports the 25 current exports. The test import surface is exactly those 25 names across 6 test files, so the barrel is a pure re-export list.
Layering:
semver.jsandscan.jsare leaves;manifest.js,pin-file.js,pin-prune.jsabove them;jspm.js,download.js,registry.js,coherence.jsabove those;pin-commands.jsandserve.json top.Watch for one real coupling.
clearVendorCache(line 707) clearsjspmCacheand is imported bypackages/server/src/dev.js:51. It must stay in the module that OWNSjspmCache(vendor/jspm.js), not in a general cache module, or the clear silently stops clearing.Verification for phase 2.
Browser and e2e do NOT apply (server-only, no request-path behaviour). Bun parity does NOT apply (no path in this phase matches the hook's runtime-sensitive regex, and the vendor engine runs identically on both runtimes through plain
fetchandnode:fs). Elision does NOT apply.The vendor suite uses a JSPM double (
framework-dev.md:204), which records refusals rather than throwing, because every fetch caller in this file catches. A split that misroutes a call site therefore shows up as a recorded refusal, not a thrown test. Read the recorded refusals in the test output, do not just check the exit code.Phase 3.
packages/server/src/check.js(1805 LOC)Barrel
packages/server/src/check.js. Modules inpackages/server/src/check/.This is the only phase that is a genuine restructure.
checkConventionsspans lines 547-1471 (925 lines) with all 18 rules inline, delimited by// --- Rule: <name> ---comments. TheRULESarray at 57-160 is metadata only; it does not dispatch. So the target architecture is the ESLint shape: one shared scan context built once, then one module per rule group that receives the context and pushes violations.RULES,RULE_NAMEScheck/rules-manifest.jshasUseServerDirective,isServerActionFile,isComponentFile,isUseServerActionFile,readElideEnabledForCheck,pathExists,gitIgnoredSetcheck/context.js(plus the corpus builder lifted out of 547-571)arrayPropUsesObject,isArrayTypeText,findFieldInitializers,BROWSER_GLOBALS,HTMLELEMENT_MEMBERS,methodBodyOf,findBrowserMemberUses,hasMultiDeclaratorExport,enumerableExports,importedLocalNames,importedValueNamescheck/ast-helpers.jscomponents-have-register,no-static-properties,reactive-props-no-class-field,array-prop-uses-array-type,no-browser-globals-in-render,no-shadowed-native-member,no-interpolation-in-raw-text-element,no-server-env-in-componentscheck/rules/components.jsno-redirect-in-api-route,shell-in-non-root-layout,form-action-not-a-get-actioncheck/rules/routing.jserasable-typescript-only,no-non-erasable-typescriptcheck/rules/typescript.jsuse-server-needs-extension,use-server-exports-callable,one-action-per-configured-filecheck/rules/server-actions.jstag-name-has-hyphen,no-duplicate-tagcheck/rules/registration.jsno-server-import-in-browser-module(withfindImportChainandcheckServerImportInBrowserModule),no-missing-local-importcheck/rules/imports.jscheckConventionsorchestrationcheck/run.jsCorrection to the issue's proposed list. The issue proposed
rules/,ast-scanner.js,reporter.js. There is no reporter in this file. Reporting lives inpackages/cli/bin/webjs.js, which formats whatcheckConventionsreturns. And the scanning is not an AST scanner, it is regex and text scanning over source built onpackages/server/src/js-scan.js, which is already a separate module.check/ast-helpers.jsabove is named for what it is, a set of source-shape predicates, andjs-scan.jsis left alone.The invariant to preserve.
checkConventionsreturns a flat violations array in a stable order. Rule modules must be invoked in the SAME order they run today, or a snapshot-shaped assertion in the check suite reorders and fails for a non-reason. Record the order incheck/run.jswith a comment naming this constraint.RULESis imported bytest/knowledge/knowledge-coverage.test.js:22, so it must stay on the barrel.Verification for phase 3.
npx webjs check --rulesoutput must be byte-identical toorigin/main. Capture both and diff.Browser, e2e, Bun, and elision do NOT apply.
check.jsis a static analyser invoked by the CLI; it is not on a request path and not on the Bun parity list.Phase 4.
packages/core/src/router-client.js(5400 LOC)Barrel
packages/core/src/router-client.js. Modules inpackages/core/src/router-client/.This is the first phase with browser-bundle exposure. Nothing inside
packages/core/srcimports this file (onlyindex.js:31andindex-browser.js:55,63do), so it is self-contained in the import graph, which is why it goes before the other core files.STREAM_MIME,ANCHOR_SUPPRESS_CEILING_MS,ANCHOR_SUPPRESS_FLOOR_MS,ANCHOR_RELEASE_EVENTS,NON_HTML_EXTENSIONS,FRAME_TOP,FALLBACK_MARKER_KEY,SNAPSHOT_CAP, the sixPREFETCH_*constants,LIVE_ATTRS,META_KEY_CSP_NONCErouter-client/constants.jsenabled,activeAbortController,currentNavigationToken,upgradeObserver,ensureUpgradeObserver,currentPageUrl,hardNavigate,_swapCommit, and their accessorsrouter-client/state.jsparseHTML,parseHTMLUnsafePreservesComments,resetParseProbe,parseDocumentPreservingCommentsrouter-client/dom-parse.jsprevScrollRestoration,releaseScrollAnchor,suppressScrollAnchoring,cancelScrollCatchUp,restoreGeneration,catchUpToRestoredScroll,afterTwoFramesrouter-client/scroll.jswarnOnce,warnIfActionSubmissionCannotDeliver,shouldFullLoadDuringParse,isPreBootNavigation,reportPreBootNavigation,reportFallback,warnDropped,warnIfSmoothScrollOnHtml,setNavigatingrouter-client/diagnostics.jsgetSubmitMethod,getSubmitAction,normalizeEnctype,getSubmitEnctype,encodeSubmitBody,buildSubmitFormDatarouter-client/form-encoder.jsfindAnchorInPath,activeFrameId,resolveTargetFrameId,markFrameBusy,clearFrameBusy,markFormBusy,clearFormBusy,trackedReloadSignaturerouter-client/frames.jscollectBoundaries,planBoundarySwaprouter-client/boundaries.jssnapshotCache,snapshotCurrent,snapshotGet,cacheKeyrouter-client/snapshot-cache.jsprefetchSaysSaveDatathroughrefreshPrefetchObservers)router-client/prefetch.jsviewTransitionsEnabled,runWithTransition,regraftPermanentElements,regraftPermanentInSlice,findInSlice,upgradeCustomElementsInRangerouter-client/view-transition.jsblurOutgoingFocus,replaceBoundaryRange,swapMarkerRange,reconcileSiblings,diffElementInPlace,isHydratedComponent,isOwnLightSlot,ownActualLightSlots,resyncEnclosingSlotRecord,resyncEnclosingHostSlots,reprojectSlottedContent,reconcileChildren,keyOf,applyOptimisticLoading,restoreOptimistic,diffChildrenrouter-client/dom-differ.jsgetCspNonce,cloneScriptWithCorrectNonce,cloneElementWithCorrectNonce,outerHTMLForDiff,metaIdentity,reconcileHeadMetas,addNewHeadElements,isPersistentHeadStyle,mergeHeadrouter-client/head-merge.jsupgradeCustomElements,upgradeTree,reactivateScripts,activateSwappedRangerouter-client/upgrade.jsapplySwaprouter-client/swap.jsforwardSuspenseResolvers,readStreamedShell,takeResolveUnit,applyStreamedResolve,streamBoundariesProgressivelyrouter-client/stream.jsrenderInPlaceNavError,handleNavigationErrorrouter-client/nav-error.jsfetchAndApplyrouter-client/fetch-apply.jsenableClientRouter,disableClientRouter,navigate,loadFrame,revalidate,performNavigation,performSubmission,buildHaveHeaderrouter-client/navigator.jsonClick,onPopState,onSubmit,closestAnchor(2656)router-client/events.js_seams, the module-scope auto-enableTwenty modules plus the barrel. Largest is
dom-differ.jsat about 710.Correction to the issue's proposed list. The issue proposed ten modules and named
form-encoder.js,dom-parse.js,scroll.js,prefetch.js,dom-differ.js,stream-processor.js,navigator.js,events.js,constants.js,state.js. Eight of those are right and kept.stream-processor.jsis renamed tostream.js, since it does not process a stream so much as read the shell and apply resolve units. The list was missing six real concerns the code actually contains, each large enough to matter: the swap (345), head merging (330), diagnostics and the fallback event (340), frames and busy markers (320), view transitions and permanent regraft (220), and the boundary scan (200). Those are added above. Turbo'sdrive/directory, verified in D8, has a directly comparable module for four of the six (view_transitioner.js,head_snapshot.js,page_renderer.js,progress_bar.js), which is the evidence they are real seams rather than invented ones.Layering, strictly downward.
constants.jsis the only true leaf.state.jsimportsconstants.js. Thendom-parse.js,scroll.js,snapshot-cache.js,form-encoder.js,boundaries.js,upgrade.js. Thendiagnostics.js,frames.js,head-merge.js,dom-differ.js,view-transition.js,stream.js,nav-error.js. Thenswap.js, which is the one that pulls the widest. Thenfetch-apply.js, thennavigator.js, thenevents.js. The barrel importsnavigator.jsandevents.jsand re-exports everything.The one back edge to plan for.
applySwapcalls intoupgradeCustomElementsInRange(view transitions) andmergeHead, andfetchAndApplycallsapplySwap, whileperformNavigationcallsfetchAndApplyand the prefetch engine calls back into the snapshot cache. Every one of those points downward in the layering above. The single edge that does NOT isreportFallbackindiagnostics.jsreachinghardNavigate, which lives instate.js, one layer below it. That resolves cleanly becausehardNavigateis a mutable binding read through an accessor, sostate.jsexposesgetHardNavigate()and never a top-levelconst.The 63
_seams stay on the barrel, unchanged in name.framework-dev.md:176andpackages/core/AGENTS.md:84both name this file as their home, and the_prefix is what exempts them from the two.d.tsguards. Do not move them into sub-modules and do not rename them.Verification for phase 4.
Phase 5.
packages/core/src/render-client.js(2939 LOC)Barrel
packages/core/src/render-client.js. Modules inpackages/core/src/render-client/.This file exports exactly ONE name,
render, so the export contract is trivial. The difficulty is entirely internal: the apply and instance functions are mutually recursive in a genuine cycle.commitInto,templateCache,submitterActionBindings,INSTANCE,currentRenderRoot,boundaryOwnerOf,commitOutOfBand,COMMIT_FAILEDrender-client/commit.jscompile,discoverSlots,assignPathsrender-client/compile.jsbuildFormActionRecord,reconcileFormActions,releaseSubmitterAction,reconcileSubmitterAction,effectiveFormAttrrender-client/form-parts.jscreateInstance,bindPart,updateInstance,clearInstance,buildDetached,disposeInstancerender-client/instance.jsresolveHoleValue,applyPart,findSlotHost,isInShadowRootEl,applyElement,applyChild,applyChildInnerrender-client/apply-part.jsapplyChildInnerRaw,nodesToFrag,removeBetweenrender-client/apply-child.jsapplyRepeatFresh,reconcileRepeat,teardownRepeatrender-client/part-repeat.jsarrayItemFirstNode,removeArrayItem,buildArrayItem,applyArrayFresh,reconcileArray,nextArrayAnchor,teardownArray,moveRange,shallowEqualArray,teardownChildrender-client/part-array.jsclearStaleDirectiveState,applyCache,applyUntil,reportOutOfBandCommitError,teardownUntil,applyWatch,teardownWatchrender-client/part-directives.jsapplyAsyncAppend,applyAsyncReplace,consumeAsyncStream,renderToNodes,teardownAsyncStreamrender-client/part-async.jsrender-client/dispatch.jsrenderitselfCorrection to the issue's proposed list. The issue proposed
template.js,expressions.js,directive-runtime.js. Those three names cover perhaps a third of the file and hide the two largest concerns, the part-type appliers (repeat, array, cache, until, watch, async, about 1145 lines together) and the form-action binding machinery (about 300 lines, which exists because of invariant 12 and has no analogue in Lit). The list above replaces them. The Lit seam cited in the old table is a PACKAGE-level split between element and template, not a within-template-engine split, so it does not prescribe these names (see D8).The cycle, and the fix.
applyPartcallsapplyChildInner, which callsapplyChildInnerRaw, which callsbuildDetachedandcreateInstance, which callbindPartandupdateInstance, which callapplyPart. Every part-type applier (reconcileRepeat,reconcileArray,applyCache,applyUntil,applyWatch, the two async ones) also calls back intoapplyChildInnerorbuildDetached. Moving these into separate modules by hand produces a dense cycle.Introduce
render-client/dispatch.js, a tiny module holding mutable function slots and aregisterRenderInternals({ applyPart, applyChildInner, buildDetached, createInstance, disposeInstance })setter, plus thin call-through wrappers. Leaf modules import the WRAPPERS fromdispatch.jsand call them;instance.jsandapply-part.jscallregisterRenderInternalsat module load. Every edge intodispatch.jsis downward, every read happens at call time rather than at module evaluation, and the TDZ class disappears. This is the same late-binding shape Lit uses forprivate-ssr-support.ts, which exists precisely solit-htmlinternals can be reached without an import cycle.Do not change what
renderdoes. The barrel keepsrenderas its own body rather than re-exporting it, so the one public name has one definition site.Verification for phase 5: identical command set to phase 4. The browser suite matters most here, since a TDZ fault in the minified bundle is invisible to Node.
Phase 6.
packages/core/src/slot.js(2282 LOC)Barrel
packages/core/src/slot.js. Modules inpackages/core/src/slot/.detectBrowser,inBrowser,SLOT_STATE,LIGHT_SLOT_ATTR,PROJECTION_ATTR,PROJECTION_ACTUAL,PROJECTION_FALLBACK,SLOT_FALLBACK_FRAG,SLOT_OWNER,SLOT_OWNER_ATTR,FLATTEN_MAX_DEPTH,RENDERING,INTERCEPTED,PARK,FRAMEWORK_DETACHEDslot/symbols.jsNATIVE_*captures,installSlotPolyfills,manualSlotFor,hostOfSlot,isInShadowRoot,lightAssignedNodes,flattenAssignedNodes,findLightAssignedSlotslot/polyfills.jsensureSlotState,hasSlotState,captureAuthoredChildren,repartition,effectiveKeyOf,adoptSSRAssignments,keyOfName,slotNameOf,appendToMapslot/state.jsprojectAuthored,applySlotAssignments,resyncActualSlots,pruneAuthoredslot/project.jscaptureNatives,withRendererWrites,installSlotSensors,processBackstop,drainRendererBackstop,processFlip,teardownSlotSensors,reconnectSweep,instanceOwnsslot/sensors.jsparkFor,isRealmNode,expandArg,guardInsertable,guardCycle,authoredSplice,isAuthoredContentSlot,isInsideAuthored,isVirtualChild,EMPTY_NODE_SET,convertVariadicArgs,commitAuthored,installSlotInterceptionslot/interception.jshasFrameworkRenderedSubtree,isOwnSlot,ownerHostFor,applyActualAssignment,applyFallback,restoreFallbackInto,rescueAssignedNodes,fireSlotChange,queueSlotChange,arraysEqualslot/assignment.jsCorrection to the issue's proposed list. The issue folded
slot.jsintosrc/component/slot-engine.js, a single module. That is wrong on two counts.slot.jsis imported byrender-client.js:16,router-client.js:24, ANDcomponent.js:10, so it is not a component-private concern, and putting it undercomponent/inverts the dependency (the renderer would import from the component tree). It also cannot be one module at 2282 lines. It gets its own directory, as above.Barrel re-exports all 31 current exports. The test import surface is 24 named symbols across 5 test files plus 3 in-package importers, all covered by the barrel.
Layering:
symbols.jsis the leaf, thenpolyfills.jsandstate.js, thensensors.jsandassignment.js, thenproject.js, theninterception.js.Verification for phase 6: identical command set to phase 4. The slot browser suites (
packages/core/test/slots/browser/*, 10 files) are the load-bearing layer here, and the native-parity contract test in particular is the counterfactual for any dropped write interception.Phase 7.
packages/core/src/component.js(1896 LOC)Barrel
packages/core/src/component.js. Modules inpackages/core/src/component/.class WebComponentBasespans lines 518-1851, which is 1334 lines in ONE class body. A class body cannot be split across modules, so this phase is not a move. The approach is to extract method BODIES into free functions that take the host element as their first argument, leaving the class body as thin delegating methods. This is behaviour-preserving as long as every extracted function keeps the exactthissemantics it had.isBrowser,defaultHasChanged,safeString,warnFunctionReflection,warnUnserializableReflectioncomponent/reflect-warnings.jsmakeServerInternals,class ServerElement,ARIA_IDL_PROPScomponent/server-element.jshyphenate,camelCasecomponent/naming.jsobservedAttributesgetter body,_assertFactoryProperties,_initializeProperties,_reflectAttribute,_hydratePropAttrs,attributeChangedCallbackbody,_reflectDeclaredAttributescomponent/properties.jsrequestUpdate,_scheduleUpdate,_performRender,_resolveUpdate,performServerUpdate,_commitAsync,_postCommit,updateComplete,getUpdateCompletecomponent/updates.jsconnectedCallback,_activate,__isHydrating,disconnectedCallbackcomponent/lifecycle.js_overridesRenderFallback,_handleRenderError,renderError,renderFallbackdefaultscomponent/render-states.jsaddController,removeControllercomponent/controllers.jsBase,FACTORY_PROPS,_propsChecked,WebComponent,propcomponent/factory.jsshouldUpdate,willUpdate,update,updated,firstUpdated,render), and the delegating one-line methodscomponent/base.jsBarrel re-exports
WebComponentandprop.Correction to the issue's proposed list. The issue proposed
base.js,properties.js,controllers.js,slot-engine.js. Three of those survive.slot-engine.jsis wrong and is handled in phase 6 (see the correction there). The list is missing the update scheduler, which is the single largest concern in the class at about 480 lines, and the server element shim at 240, which exists because SSR runs the constructor against aServerElementrather than anHTMLElementand is entirely separable.Stated fallback. If
component/base.jsstill exceeds the 1000-line ceiling after the extraction, record it as the single named exemption under D3 rather than inventing a mixin chain.lit/packages/reactive-element/src/reactive-element.tsis 1754 lines for exactly this reason (verified in D8), so a large single class at this seam is the industry outcome, not a failure.The invariant that makes or breaks this phase.
packages/server/src/check.jsenforcesreactive-props-no-class-fieldandno-static-propertiesagainst user code, andcomponent.jsitself throws at runtime when an app declaresstatic properties. The extraction must not change WHEN_assertFactoryPropertiesruns relative to_initializeProperties, or the throw moves and the check-rule tests disagree with the runtime.Verification for phase 7: identical command set to phase 4, plus the type fixtures, which pin
WebComponent's signature.Phase 8.
packages/core/src/render-server.js(2427) pluspackages/server/src/ssr.js(2394)Two barrels, two directories, one PR. They are not coupled by import (
ssr.js:3reaches core through the package specifier), they are coupled through the SSR byte contract and the streaming protocol. One PR gives one verification pass over both halves.packages/core/src/render-server.js, modules inpackages/core/src/render-server/.CHAR_REF,NAMED,LEGACY,decodeAttrEntities,decodeNamed,codePointsToString,fromCodePoint,consumePropAttrsrender-server/entities.jsendOfComment,endOfScriptContent,inertRanges,inRanges,inertAt,VOID_ELEMENTS,isVoidElement,findClosingTagInString,escapeRegex,isRawtextTag,isRcdataTag,isTextOnlyTagrender-server/html-scan.jsparseAttrs,seedServerAttrs,withHostMarker,appendReflectedAttrs,applyAttrsToInstance,camelCase,kebabCaserender-server/instance.jsSSR_BROWSER_GLOBALS,SSR_HTMLELEMENT_METHODS,browserMemberHint,isProd,defaultSSRErrorTemplaterender-server/ssr-errors.jsinjectDSDrender-server/dsd.jsextractSlotAttr,partitionAuthoredBySlot,appendStringToMap,substituteSlotsInRenderrender-server/slots.jsprocessSuspenseElementsrender-server/suspense.jsrenderToString,render,renderTemplaterender-server/string.jsrenderToStream,streamRender,streamTemplate,streamSuspenseBoundariesrender-server/stream.jsBarrel re-exports
renderToStringandrenderToStream.Correction to the issue's proposed list. The issue folded both files into one
packages/server/src/ssr/directory withlayout-chain.js,boundary-emitter.js,metadata.js,stream-builder.js,dsd-serializer.js. That is wrong on package boundaries:render-server.jsis in@webjsdev/coreand is reached through@webjsdev/core/server, so it cannot move into@webjsdev/server. The two directories above replace the single one. Withinssr.js,boundary-emitter.jsdoes not correspond to anything, since boundary markers are emitted bywrapWithChildrenMarkerinside the segment-path helpers, not by a dedicated emitter.packages/server/src/ssr.js, modules inpackages/server/src/ssr/.loadingSegmentPath,layoutSegmentPath,pageSegmentPath,regionRouteKey,wrapWithChildrenMarkerand their export blockssr/segments.jsnearest,ssrBoundaryHtml,ssrNotFoundHtml,renderChain,loadingTemplatesssr/layout-chain.jscollectMetadata,hoistHeadTags,serializeViewportssr/metadata.jsextractUserShell,buildHeadInner,buildDocumentParts,wrapInDocument,collectHoistedHeadTags,publicEnvShimssr/document.jssetClientRouterEnabled,clientRouterEnabled,wrapHeadssr/head.jscomponentPreloads,deduplicatedPreloads,reachedVendorSpecifiers,preloadCrossOriginAttr,integrityAttrssr/preloads.jsprivateFragment,htmlResponse,cachedHtmlResponse,streamingHtmlResponsessr/response.jsloadModule,toUrlPath,getNonce,escapeHtml,escapeAttr,escapeJsonLd,jsonLdScriptssr/util.jsssrPage,ssrNotFound,ssrForbidden,ssrUnauthorizedssr/page.jsBarrel re-exports all 18 current exports including the 9
_seams (_hoistHeadTags,_pageSegmentPath,_regionRouteKey,_wrapWithChildrenMarker,_extractUserShell,_buildDocumentParts,_escapeJsonLd,_jsonLdScript, and the fourth in thewrapWithChildrenMarkerblock).Layering on the server side:
util.jsis the leaf, thensegments.js,metadata.js,preloads.js, thendocument.jsandhead.js, thenresponse.js, thenlayout-chain.js, thenpage.js.Also in this PR. Widen the Bun parity hook regex from
/ssr\.jsto/ssr[./]per D10, or every future edit underpackages/server/src/ssr/silently escapes the gate.The invariant to preserve. The keyed boundary comment pairs (
<!--wj:children:<segment>:<route-key>-->) are the client router's strict scan target, and a single byte of drift degrades every soft navigation to a full page load.wrapWithChildrenMarkerandregionRouteKeymust produce byte-identical output.webjs elision --verifycovers this directly, and so doestest/bun/keyed-boundaries.mjs.Verification for phase 8.
webjs-core-browser.jsshould not move at all in this phase, sincerender-server.jsis excluded from the browser entry by design (index-browser.js:5). A non-zero delta there means the split leaked a server module into the browser bundle, which is a hard fail, not a tolerance question.Phase 9.
packages/server/src/dev.js(3070 LOC)Barrel
packages/server/src/dev.js. Modules inpackages/server/src/dev/.Largest blast radius in the sequence: 62 test files import it by relative path, it is the boot path of every app, and it is runtime-sensitive on Bun. It goes last so every mechanic in this plan is already proven.
This is the one file whose main function must be decomposed rather than moved.
createRequestHandlerspans lines 536-1682, which is 1147 lines in one closure. It already funnels its mutable data through astateobject (state.routeTable,state.lastDevError,state.regenerateRules) and already hands actxrecord tostartNodeListener, so the pattern is half present. Finish it: build one explicitbootrecord plus the existingstate, and pass both to the extracted phase functions instead of capturing them.MIME,TS_CACHE_MAX,kebab,resolveRequestId,shouldAccessLogdev/mime.jsloadAppEnv,elideEnvOverride,readElideEnabled,seedEnvOverride,readSeedEnabled,readClientRouterEnabled,readHeaderRules,readRedirectRules,readTrailingSlashFromApp,readBasePathFromApp,warnOnInvalidWebjsConfig,readAllowedOriginsFromApp,readCspConfigFromApp,readBodyLimitsFromApp,readDevWatchPathsFromApp,readServerTimeoutsFromAppdev/app-config.jscreateRequestHandler(env load, config reads, provider wiring, importmap binding, the action load hook,reportError)dev/handler/boot.jsemitRouteTypes,ensureReady,resolveAndApplyVendor,rebuild,doRebuilddev/handler/analysis.jsreportDevError,getReadinessCheckdev/handler/readiness.jshandle,produce,routeFordev/handler/dispatch.jsdev/request-handler.jsshouldIgnoreWatchPath,fileByteHash,frameworkServerVersion,debouncedev/watch.jsstartServerdev/start-server.jsstartNodeListenerdev/listener-node.jstryServeFrameworkStaticdev/framework-static.jshandleCore,wantsJsondev/internal-routes.jsrunWithSegmentMiddleware,ROOT_MIDDLEWARE_FILES,loadMiddlewaredev/middleware-chain.jsmakeHttpServer,toWebRequest,sendWebResponsedev/http-io.jsfileResponse,jsModuleResponse,exists,stripTs,tsResponsedev/static-serve.jscollectRouteModules,computeBrowserBoundFiles,appTopLevelDirs,locateCoreDir,locatePackageDirdev/app-analysis.jsDEV_OVERLAY_SRC,RELOAD_WORKER_SRC,reloadClientJs,__webjsApplyError,__webjsReloadWhenReady,__webjsDirectEvents,reloadWorkerJsdev/reload-client.jsBarrel re-exports the 16 current exports. The internal symbols the tests import are
createRequestHandler,startServer,readDevWatchPathsFromApp,shouldIgnoreWatchPath,readElideEnabled,readSeedEnabled, all covered.Correction to the issue's proposed list. The issue proposed
config-reader.js,http-server.js,watcher.js,hmr.js,error-overlay.js,route-dispatcher.js. Four of those map onto real seams and are kept under different names.hmr.jsdoes not exist as a concept in this file: WebJs has no HMR, it has a full-reload SSE channel, and the code for it is thereload-client.jsgenerator plus the SSE hub inlistener-core.js, which is already a separate module.error-overlay.jsis likewise already separate (packages/server/src/dev-error.jsbuilds the frame,dev-overlay.jsis the client script), and what lives indev.jsis onlyreportDevError, which is 35 lines and belongs with readiness. The six missing concerns the list above adds are the boot sequence, the readiness and rebuild machinery, the internal/__webjs/*route table, the node listener shell, the TypeScript-stripping static serve, and the app analysis helpers.The naming caveat, recorded.
dev/is inherited mechanically from the barrel's basename per D2, and it is a poor description of the contents, sincestart-server.jsandapp-config.jsrun in production. Put a banner comment saying so at the top ofpackages/server/src/dev.js. Renaming tosrc/server/would cost 62 test import sites for zero behaviour.Also in this PR. Widen the Bun parity hook regex from
/dev\.jsto/dev[./]per D10.The three invariants to preserve.
createRequestHandleris load-bearing and documented in the comments.loadAppEnvmust run beforeapplyEnvValidation, which must run beforerunInstrumentation, which must run before the route table and action index are built, and the'use server'load hook must install before any action module is imported (ESM caches by URL). Extracting the boot sequence must preserve that exact order, and the extracted function must document it.setBasePathruns beforesetCoreInstallandsetVendorEntries, which run beforesetAssetRoots, so the published build id is a stable deploy fingerprint. Same constraint, different chain.startServerpicks the listener shell by runtime (serverRuntime() === 'bun'dynamically importslistener-bun.js, otherwisestartNodeListener). Do not move the dynamic import to a static one, or theBun.*global is referenced on Node.Verification for phase 9.
Tests
This refactor is behaviour-preserving, so the usual "add a test that fails when reverted" rule inverts. The existing suites ARE the counterfactual. A behaviour-preserving move that breaks something breaks an existing test; a move that breaks nothing needs no new assertion of its own. What the split DOES introduce as a new failure mode is a silently dropped export, and that is covered by the one new test in D9 plus the per-phase export diff.
The one new test.
test/architecture/barrel-surface.test.mjs, added in phase 1, holding the ten export-count floors from D9. It imports each barrel and assertsObject.keys(mod).filter(k => k !== 'default').length >= floor. Rationale and the anti-rot argument are in D9.Per-phase layer applicability.
webjs doctorin 3 appswebjs doctorin 3 appswebjs checkin 3 appsWhy the first three skip four layers.
lib/doctor.js,src/vendor.js, andsrc/check.jsnever reach a browser, never run on a request path, and none of their paths matches the Bun parity hook's runtime-sensitive regex.webjs elision --verifyhas nothing to compare, since none of them participates in SSR. Running those layers anyway costs about forty minutes per phase for zero signal. This is a decision, not an omission.Bun parity is mandatory for phases 4 through 9 and is enforced, not merely expected:
.claude/hooks/require-bun-parity-with-runtime-src.shBLOCKS the commit when a runtime-sensitive path is staged with notest/bun/**file alongside. The escape hatch isWEBJS_BUN_VERIFIED=1and it is not the honest answer here, because these are exactly the surfaces the gate exists for. Thetest/bun/**files already in play, none of which needs a new sibling since the behaviour does not change.keyed-boundaries.mjs,nav-sentinels.mjs,form-action-dispatch.mjs,form-action-submitter-parity.test.mjs,routing-boundaries.mjsbinding-prefixes.mjs,comment-not-an-element.mjs,form-action-guard.mjs,host-display-default.mjsslot-ssr-parity.mjsattribute-converter-parity.mjs,attribute-reader-parity.mjs,reflect-function-guard.mjs,reflect-unserializable.mjs,host-display-default.mjsseed.mjs,action-seed-circular.test.mjs,keyed-boundaries.mjs,core-modulepreload.mjs,asset-url.mjs,slot-ssr-parity.mjslistener.mjs,listener-overhead.mjs,app-boot.mjs,dev-hot-reload.mjs,dev-extra-watch.mjs,dev-regenerate.mjs,dev-reload-retry.mjs,dev-overlay-scope.mjs,path-alias.mjs,root-middleware.mjs,timeouts.mjs,webjs-config-validate.mjs,runtime-smoke.test.mjs,compression.mjs,forwarded-proto.mjs,forwarded-trust.mjsThe whole set runs with
node scripts/run-bun-tests.js. Satisfy the hook by touching the relevanttest/bun/**file with the phase's assertion re-run, or by staging a comment recording which Bun tests were run and their result. Do not reach for the escape hatch.Two known-flaky-in-a-worktree results, so they are not chased. Five tests fail in a
worktree:linkworktree and pass in the primary checkout and in CI (the listener and listener-overhead pair plus three elision assertions). If exactly those five fail, it is the worktree, not the phase. Confirm by re-running them in a worktree cut fromorigin/mainwith no changes.Rebuild
distbefore e2e and Bun, every core phase.node scripts/build-framework-dist.js. Skipping it makes the whole e2e and Bun run vacuous, since both resolve the built bundle rather thansrc.Docs
Every path below was checked at HEAD
509d8809. Where nothing changes, that is stated so the implementer does not go looking.Real doc surfaces that MUST change.
AGENTS.md:162("Framework source: where to find it")@webjsdev/server/src/ssr.js,@webjsdev/core/src/render-client.js,@webjsdev/core/src/router-client.js,@webjsdev/server/src/check.js). All four still RESOLVE after the split, since the barrel keeps the path. Add one sentence saying each is now a barrel over a sibling directory, so an agent reading it knows to look one level downpackages/core/AGENTS.md:84and:274src/router-client.jsas the home of the 63_seams. The seams stay on the barrel, so the claim stays true. Add the directory pointerpackages/core/AGENTS.md:113packages/server/src/ssr.jsas what the metadata surface is derived from. Point atssr/metadata.jspackages/server/AGENTS.mdwebjs.*config-reader inventory, all of which moves todev/app-config.js. Update the paths in placepackages/cli/AGENTS.md:193readDoctorPolicyandapplyDoctorPolicyas living inlib/doctor.js. They move tolib/doctor/policy.jsand are re-exported. Update the sentenceframework-dev.md:176src/router-client.jsas the home of the 63_seams for the.d.tsguard exemption. Still true, add the directory pointerframework-dev.md:204packages/server/src/vendor.jsas the file whose fetch callers all catch, which is why the JSPM double records refusals. Point atvendor/jspm.jsframework-dev.md:244packages/server/src/dev.jsas the file that statically imports core symbols, which forces the core-publishes-first order. Point atdev/handler/boot.jsframework-dev.md:259reportDevErrorplus the SSE push inpackages/server/src/dev.js. Point atdev/handler/readiness.jspackages/mcp/src/mcp.js:123and:160,packages/mcp/src/mcp-source.js:16and:212server/src/ssr.jsas an EXAMPLE path in a tool description and an error message. Thesourcetool reads any path under the@webjsdev/*src trees behind a traversal guard, so it keeps working with no code change and a nested path is readable. Refresh the example strings so an agent following them lands on something informative.claude/hooks/require-bun-parity-with-runtime-src.shSurfaces that do NOT change, checked.
website/app/docs/**) documents the framework's public API, not its file layout. The one exception iswebsite/app/docs/no-build/page.ts:51, which prints the literal"@webjsdev/core/client-router": "/__webjs/core/src/router-client.js"as an importmap example. Because D2 keeps the path, that string stays correct. This is the single strongest reason the rename was rejected.website/app/why-webjs/page.ts:150-151printscore/src/render-server.js: export async function renderToString(andserver/src/ssr.js: const html = await renderToString(tree)as illustration. Both stay correct, since both barrels keep their path andrenderToStringstays exported fromrender-server.js..agents/skills/webjs/**names exactly one framework source path,node_modules/@webjsdev/core/src/component.jsinreferences/components.md:417, as a place to grep the base surface. The barrel keeps that path resolvable. Add "and its siblingcomponent/directory" in phase 7 so the grep advice stays useful.packages/cli/templates/**) name no framework source path. Nothing to sync, so thewebjs-scaffold-syncskill does not apply to any phase.README.mddocuments capabilities, not file layout. No change.CONVENTIONS.mdcovers app conventions. No change.The doc gate.
.claude/hooks/require-docs-with-src.shBLOCKS a commit that stagespackages/*/srcorpackages/cli/libwith no doc surface alongside. Every phase in this plan stages a real doc surface (the table above assigns at least one to each of phases 1, 2, 3, 4, 8, 9, and phases 5, 6, 7 stagepackages/core/AGENTS.mdand.agents/skills/webjs/references/components.md). SoWEBJS_NO_DOC_GATE=1is NOT the honest answer for any phase and must not be used. A pure internal move that genuinely changed no documented surface would be the case for it, and this refactor is not that: the "Framework source" sections inAGENTS.md,framework-dev.md, and all three per-packageAGENTS.mdfiles exist specifically to tell a cold agent where to look, and this refactor changes where to look. Leaving them stale reproduces the #488 gap the hook was written for.Invoke the
webjs-doc-syncskill on each phase to confirm no surface was missed.Acceptance criteria
origin/mainimmediately before opening.package.jsonexports,files, ortypesentry changed in any of the three packages.sh find packages/core/src/router-client packages/core/src/render-client packages/core/src/render-server \ packages/core/src/component packages/core/src/slot packages/server/src/dev \ packages/server/src/vendor packages/server/src/ssr packages/server/src/check packages/cli/lib/doctor \ -name '*.js' -exec wc -l {} + | awk '$1 > 1000 && $2 != "total"'It binds only modules produced by this issue. Barrels are exempt. Any exemption beyond
packages/core/src/component/base.jsmust be named in the merging PR with its measured size and reason. No CI guard is added (see D3).missingand emptyadded, pasted in each PR body.test/architecture/barrel-surface.test.mjsexists with all ten floors and passes.node find-cycles.mjs packages/core/src packages/server/src packages/cli/libreports zero cycles after every phase, as it does at HEAD.node --input-type=module -e "await import('./packages/core/index.js')"and the same forindex-browser.jsboth load cleanly after every core phase.packages/core/dist/webjs-core-browser.jsgrew by no more than 1.0% in any single core phase and no more than 2.0% cumulatively from its 137,038-byte baseline. Sizes recorded before and after in each PR body.packages/core/dist/webjs-core.jsdid not grow at all in phase 8, sincerender-server.jsis excluded from the browser entry and its split must not leak anywhere.npm test,npm run test:browser, the six e2e files underWEBJS_E2E=1, andnode scripts/run-bun-tests.jsare green on the phases the Tests table marks, with no new skips and noWEBJS_BUN_VERIFIED=1.npx webjs elision --verifyreports parity ingallery,examples/blog, andwebsiteon phases 4 through 9, with a non-zero compared count in each (a zero-compared run is a vacuous pass and fails this criterion).npx webjs checkreports zero violations andnpx webjs doctorexits zero in all three apps, on every phase.npx webjs check --rulesoutput is byte-identical toorigin/mainafter phase 3..claude/hooks/require-bun-parity-with-runtime-src.shmatches every new path underpackages/server/src/ssr/andpackages/server/src/dev/, per D10, verified by staging one file from each directory and confirming the hook fires.WEBJS_NO_DOC_GATE=1appears in no commit.Out of scope
Behaviour-preserving move only. The implementer must not widen into any of the following.
addedin the D9 export diff must be empty, which mechanically forbids this.webjs build, no dev-mode bundling to claw back the dev-only request count from D6. Those are deliberately deferred perAGENTS.md.exportsentries measured in D2, not by compatibility.packages/core/src/html-entities.js(2179, a generated data table),packages/cli/bin/webjs.js(1665),packages/cli/lib/create.js(1636),packages/editors/intellisense/src/index.js(1493),packages/server/src/component-elision.js(1374),packages/core/src/form-action.js(1258). They are over 800 lines and they are not this issue's business.packages/server/src/dev/to something more accurate. The caveat is recorded in P9 and the 62 import sites are the reason.Staleness discipline. Every line anchor in this plan is dated to HEAD
509d8809. This issue's file surface overlaps essentially every other in-flight change in the repo, so anchors age fast. Each phase MUSTgit fetch originand cut its worktree fromorigin/mainimmediately before starting, and MUST re-derive its ranges withgrep -nrather than trusting the numbers here. A phase that opens more than a few days after the previous one merged should re-run the structural greps in full before writing a line.