@webjsdev/server 0.8.4 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/dev.js +79 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
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
- async function handleCore(req, ctx) {
779
- const { state, appDir, coreDir, dev } = ctx;
780
- const url = new URL(req.url);
781
- // Decode percent-encoded characters so filesystem lookups match real
782
- // filenames. Dynamic route segments like `[slug]` and route groups like
783
- // `(marketing)` contain chars that browsers percent-encode in URLs
784
- // (`%5B`, `%5D`, `%28`, `%29`). Without decoding, the server joins the
785
- // encoded path with the app directory file not found 404 no JS
786
- // loads → no interactivity.
787
- let path;
788
- try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
789
- const method = req.method.toUpperCase();
790
-
791
- // Health / readiness probes (`/__webjs/health`, `/__webjs/ready`) are handled
792
- // in `handle()` BEFORE ensureReady, so they are not repeated here.
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
- // `@webjsdev/core` ships different bytes at the same URL. An
807
- // `immutable` cache-control directive at an edge CDN (Cloudflare,
808
- // Vercel, Fly) keeps the prior bytes pinned for up to a year, which
809
- // silently bricks the next deploy: browsers load the old client
810
- // renderer against a server emitting the new SSR shape, and any
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
- if (!abs.startsWith(coreDir)) return new Response('forbidden', { status: 403 });
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
- // In default pin mode (or no-pin mode) the importmap routes bare
824
- // imports straight to ga.jspm.io URLs and the browser bypasses this
825
- // server entirely. When the user ran `webjs vendor pin --download`,
826
- // the importmap has local `/__webjs/vendor/<file>.js` URLs and this
827
- // handler serves the committed bundle files from `.webjs/vendor/`.
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) {