@webjsdev/server 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/component-elision.js +42 -0
- package/src/dev.js +79 -35
package/package.json
CHANGED
package/src/component-elision.js
CHANGED
|
@@ -132,6 +132,21 @@ const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientR
|
|
|
132
132
|
|
|
133
133
|
/** Identifiers that only exist in a browser; their presence means client work. */
|
|
134
134
|
const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Cross-module observation of a component's registration. A module that
|
|
138
|
+
* observes another component's tag forces that component to register on the
|
|
139
|
+
* client, so the observed component cannot be elided even when its own render
|
|
140
|
+
* is display-only (eliding it drops its `customElements.define`, after which
|
|
141
|
+
* the observation silently fails). These scan for the three statically visible
|
|
142
|
+
* forms; the captured group is the observed tag (or class) name.
|
|
143
|
+
* - `customElements.whenDefined('my-tag')` / `whenDefined("my-tag")`
|
|
144
|
+
* - a CSS `my-tag:defined { … }` selector
|
|
145
|
+
* - `x instanceof MyClass` (mapped back to a tag via the component's className)
|
|
146
|
+
*/
|
|
147
|
+
const WHEN_DEFINED_RE = /\bwhenDefined\s*\(\s*['"`]([a-z][a-z0-9]*-[a-z0-9-]*)['"`]/g;
|
|
148
|
+
const TAG_DEFINED_RE = /\b([a-z][a-z0-9]*-[a-z0-9-]*):defined\b/g;
|
|
149
|
+
const INSTANCEOF_RE = /\binstanceof\s+([A-Z][A-Za-z0-9_$]*)/g;
|
|
135
150
|
/** Same, for component source, minus `customElements` (the registration call
|
|
136
151
|
* `customElements.define(...)` legitimately uses it and must not force ship). */
|
|
137
152
|
const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;
|
|
@@ -598,9 +613,12 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
598
613
|
const componentFiles = new Set();
|
|
599
614
|
/** @type {Map<string, string>} */
|
|
600
615
|
const tagToFile = new Map();
|
|
616
|
+
/** @type {Map<string, string>} className -> file, for instanceof observation */
|
|
617
|
+
const classToFile = new Map();
|
|
601
618
|
for (const c of components) {
|
|
602
619
|
componentFiles.add(c.file);
|
|
603
620
|
tagToFile.set(c.tag, c.file);
|
|
621
|
+
if (c.className) classToFile.set(c.className, c.file);
|
|
604
622
|
}
|
|
605
623
|
|
|
606
624
|
/** @type {Set<string>} */
|
|
@@ -615,6 +633,9 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
615
633
|
const clientGlobalOrBareFiles = new Set();
|
|
616
634
|
/** @type {Set<string>} */
|
|
617
635
|
const serverFiles = new Set();
|
|
636
|
+
/** @type {Set<string>} component files forced to ship because some module
|
|
637
|
+
* observes their registration (whenDefined / :defined / instanceof). */
|
|
638
|
+
const observedComponentFiles = new Set();
|
|
618
639
|
|
|
619
640
|
/** @type {Set<string>} */
|
|
620
641
|
const allFiles = new Set(componentFiles);
|
|
@@ -646,8 +667,29 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
646
667
|
if (componentFiles.has(file) && analyzeComponentSource(src).interactive) {
|
|
647
668
|
mustShip.add(file);
|
|
648
669
|
}
|
|
670
|
+
// Cross-module registration observation (#169): if THIS module observes
|
|
671
|
+
// another component's tag, that component must register client-side, so
|
|
672
|
+
// it cannot be elided. Map each observed tag/class back to its component
|
|
673
|
+
// file. Resolution against tagToFile / classToFile happens after the loop
|
|
674
|
+
// (all components are known up front, but we collect here while we hold
|
|
675
|
+
// each source). Verdict-safe: only ever forces MORE components to ship.
|
|
676
|
+
for (const m of src.matchAll(WHEN_DEFINED_RE)) {
|
|
677
|
+
const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
678
|
+
}
|
|
679
|
+
for (const m of src.matchAll(TAG_DEFINED_RE)) {
|
|
680
|
+
const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
681
|
+
}
|
|
682
|
+
for (const m of src.matchAll(INSTANCEOF_RE)) {
|
|
683
|
+
const f = classToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
684
|
+
}
|
|
649
685
|
}
|
|
650
686
|
|
|
687
|
+
// Force every observed component to ship before the fixpoint runs, so the
|
|
688
|
+
// render/import rules propagate from it too. Dynamic tag strings and external
|
|
689
|
+
// (non graph-reachable) stylesheets remain an author-facing caveat, since
|
|
690
|
+
// static analysis cannot see them.
|
|
691
|
+
for (const f of observedComponentFiles) mustShip.add(f);
|
|
692
|
+
|
|
651
693
|
// Reverse import edges (who imports each file), built once from the graph.
|
|
652
694
|
// Drives both the closure-client-work reachability below and the fixpoint's
|
|
653
695
|
// import rule, each in O(N+E) rather than a per-component closure walk.
|
package/src/dev.js
CHANGED
|
@@ -549,6 +549,16 @@ export async function createRequestHandler(opts) {
|
|
|
549
549
|
}
|
|
550
550
|
return Response.json({ status: 'ok' }, { headers: noStore });
|
|
551
551
|
}
|
|
552
|
+
// Framework-internal static assets (the @webjsdev/core runtime, the dev
|
|
553
|
+
// reload client, downloaded vendor bundles) depend on neither the analysis
|
|
554
|
+
// nor the vendor importmap, so serve them BEFORE ensureReady(). Otherwise a
|
|
555
|
+
// cold instance blocks them behind the first vendor resolve (issue #190),
|
|
556
|
+
// and the core bundle is on every page's boot path, so that stalled first
|
|
557
|
+
// interactivity site-wide. Matched on the decoded path, like handleCore.
|
|
558
|
+
let assetPath = probePath;
|
|
559
|
+
try { assetPath = decodeURIComponent(probePath); } catch { /* keep raw on malformed escape */ }
|
|
560
|
+
const staticResp = await tryServeFrameworkStatic(assetPath, req.method.toUpperCase(), { coreDir, appDir, dev });
|
|
561
|
+
if (staticResp) return staticResp;
|
|
552
562
|
// Build all whole-app analysis on the first request (memoized), before
|
|
553
563
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
554
564
|
await ensureReady();
|
|
@@ -775,23 +785,29 @@ export async function startServer(opts) {
|
|
|
775
785
|
* @param {Request} req
|
|
776
786
|
* @param {{state: any, appDir: string, coreDir: string, dev: boolean}} ctx
|
|
777
787
|
*/
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
788
|
+
/**
|
|
789
|
+
* Serve framework-internal static assets that depend on NEITHER the whole-app
|
|
790
|
+
* analysis NOR the vendor importmap: the `@webjsdev/core` runtime files, the
|
|
791
|
+
* dev reload client, and (in `--download` pin mode) the committed vendor
|
|
792
|
+
* bundles. `handle()` calls this BEFORE `ensureReady()`, so a cold instance
|
|
793
|
+
* returns them immediately instead of blocking on the first vendor resolve
|
|
794
|
+
* (issue #190). The core bundle is on every page's boot path, so coupling it
|
|
795
|
+
* to the jspm resolve stalled first interactivity site-wide on a cold instance.
|
|
796
|
+
*
|
|
797
|
+
* Like the health / readiness probes (also answered pre-`ensureReady`), these
|
|
798
|
+
* bypass app middleware. That is correct: they are framework infrastructure the
|
|
799
|
+
* app needs to function, not app routes, and `state.middleware` is not even
|
|
800
|
+
* loaded until `ensureReady()` completes.
|
|
801
|
+
*
|
|
802
|
+
* @param {string} path decoded pathname
|
|
803
|
+
* @param {string} method upper-cased HTTP method
|
|
804
|
+
* @param {{ coreDir: string, appDir: string, dev: boolean }} ctx
|
|
805
|
+
* @returns {Promise<Response|null>} a Response, or null when path is not one of these assets
|
|
806
|
+
*/
|
|
807
|
+
async function tryServeFrameworkStatic(path, method, ctx) {
|
|
808
|
+
const { coreDir, appDir, dev } = ctx;
|
|
793
809
|
|
|
794
|
-
// Dev live-reload client
|
|
810
|
+
// Dev live-reload client.
|
|
795
811
|
if (path === '/__webjs/reload.js') {
|
|
796
812
|
if (!dev) return new Response('Not found', { status: 404 });
|
|
797
813
|
return new Response(RELOAD_CLIENT_JS, {
|
|
@@ -802,35 +818,38 @@ async function handleCore(req, ctx) {
|
|
|
802
818
|
// Core module: /__webjs/core/*
|
|
803
819
|
//
|
|
804
820
|
// ETag + ~1h max-age, NOT immutable. The URL path is un-versioned
|
|
805
|
-
// (`/__webjs/core/src/render-client.js` etc.), so bumping
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
// exports added in the bump (e.g., the slot.js entry points landed
|
|
821
|
+
// (`/__webjs/core/src/render-client.js` etc.), so bumping `@webjsdev/core`
|
|
822
|
+
// ships different bytes at the same URL. An `immutable` cache-control
|
|
823
|
+
// directive at an edge CDN (Cloudflare, Vercel, Fly) keeps the prior bytes
|
|
824
|
+
// pinned for up to a year, which silently bricks the next deploy: browsers
|
|
825
|
+
// load the old client renderer against a server emitting the new SSR shape,
|
|
826
|
+
// and any exports added in the bump (e.g., the slot.js entry points landed
|
|
812
827
|
// for 0.6.0) resolve to undefined in the cached file.
|
|
813
828
|
// Regression: 2026-05-20, ui.webjs.dev tier-2 components after
|
|
814
829
|
// @webjsdev/core 0.5.0 -> 0.6.0 republish.
|
|
815
830
|
if (path.startsWith('/__webjs/core/')) {
|
|
816
831
|
const rel = path.slice('/__webjs/core/'.length);
|
|
817
832
|
const abs = resolve(coreDir, rel);
|
|
818
|
-
|
|
833
|
+
// Trailing-separator boundary check, not a raw string prefix: a raw
|
|
834
|
+
// `startsWith(coreDir)` would admit a sibling like `@webjsdev/core-evil`,
|
|
835
|
+
// reachable via an encoded slash (`..%2f`, which survives URL normalization
|
|
836
|
+
// and then decodes to `../`). Match the public-root branch's guard.
|
|
837
|
+
if (abs !== coreDir && !abs.startsWith(coreDir + sep)) {
|
|
838
|
+
return new Response('forbidden', { status: 403 });
|
|
839
|
+
}
|
|
819
840
|
return fileResponse(abs, { dev, immutable: false });
|
|
820
841
|
}
|
|
821
842
|
|
|
822
|
-
// Vendor URL handler for `webjs vendor pin --download` mode only.
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
843
|
+
// Vendor URL handler for `webjs vendor pin --download` mode only. In default
|
|
844
|
+
// pin mode (or no-pin mode) the importmap routes bare imports straight to
|
|
845
|
+
// ga.jspm.io URLs and the browser bypasses this server entirely. When the
|
|
846
|
+
// user ran `webjs vendor pin --download`, the importmap has local
|
|
847
|
+
// `/__webjs/vendor/<file>.js` URLs and this serves the committed bundle files
|
|
848
|
+
// from `.webjs/vendor/`. These are read-only static content: allow GET/HEAD
|
|
849
|
+
// for the normal fetch, OPTIONS for any cross-origin preflight (204 with the
|
|
850
|
+
// same Allow header rather than 405, which some intermediaries treat as a
|
|
851
|
+
// hard failure even for a CORS probe), and 405 everything else.
|
|
828
852
|
if (path.startsWith('/__webjs/vendor/') && path.endsWith('.js')) {
|
|
829
|
-
// Vendor bundles are read-only static content. Allow GET/HEAD for
|
|
830
|
-
// the normal fetch, OPTIONS for any cross-origin preflight (we
|
|
831
|
-
// return 204 with the same Allow header rather than 405, which
|
|
832
|
-
// some intermediaries treat as a hard failure even for a CORS
|
|
833
|
-
// probe), and 405 everything else.
|
|
834
853
|
if (method === 'OPTIONS') {
|
|
835
854
|
return new Response(null, { status: 204, headers: { allow: 'GET, HEAD, OPTIONS' } });
|
|
836
855
|
}
|
|
@@ -846,6 +865,31 @@ async function handleCore(req, ctx) {
|
|
|
846
865
|
return resp;
|
|
847
866
|
}
|
|
848
867
|
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function handleCore(req, ctx) {
|
|
872
|
+
const { state, appDir, coreDir, dev } = ctx;
|
|
873
|
+
const url = new URL(req.url);
|
|
874
|
+
// Decode percent-encoded characters so filesystem lookups match real
|
|
875
|
+
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
876
|
+
// `(marketing)` contain chars that browsers percent-encode in URLs
|
|
877
|
+
// (`%5B`, `%5D`, `%28`, `%29`). Without decoding, the server joins the
|
|
878
|
+
// encoded path with the app directory → file not found → 404 → no JS
|
|
879
|
+
// loads → no interactivity.
|
|
880
|
+
let path;
|
|
881
|
+
try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
|
|
882
|
+
const method = req.method.toUpperCase();
|
|
883
|
+
|
|
884
|
+
// Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) and the
|
|
885
|
+
// framework-internal static assets (`/__webjs/core/*`, `/__webjs/reload.js`,
|
|
886
|
+
// downloaded `/__webjs/vendor/*`) are served in `handle()` BEFORE ensureReady,
|
|
887
|
+
// so they are not repeated here. This fallback covers the (currently
|
|
888
|
+
// unreachable) case of handleCore being entered for one of those assets, so
|
|
889
|
+
// the routing stays correct if a future caller bypasses the early path.
|
|
890
|
+
const frameworkStatic = await tryServeFrameworkStatic(path, method, { coreDir, appDir, dev });
|
|
891
|
+
if (frameworkStatic) return frameworkStatic;
|
|
892
|
+
|
|
849
893
|
// Internal server-action RPC endpoint
|
|
850
894
|
const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
|
|
851
895
|
if (actMatch) {
|