@webjsdev/server 0.9.0 → 0.10.0

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 (101) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +427 -109
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +96 -14
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +262 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/dev/bun-app-source.js +95 -5
  33. package/src/dev/config.js +7 -51
  34. package/src/dev/dev-service-worker.js +48 -0
  35. package/src/dev/esm-hot-hooks.js +373 -0
  36. package/src/dev/handler.js +502 -155
  37. package/src/dev/helpers.js +127 -96
  38. package/src/dev/hot-host.js +33 -6
  39. package/src/dev/poke-state.js +110 -0
  40. package/src/dev/serve.js +177 -129
  41. package/src/dev/server.js +92 -33
  42. package/src/dev/specifier-versions.js +125 -0
  43. package/src/dev/watch-ignore.js +49 -4
  44. package/src/dev/watch-poll.js +78 -0
  45. package/src/dev/watch-tree.js +59 -22
  46. package/src/dev-classify.js +59 -26
  47. package/src/dev-error.js +104 -3
  48. package/src/dev-hot-classify.js +291 -0
  49. package/src/dev-hot-client.js +245 -0
  50. package/src/dev-import.js +22 -5
  51. package/src/dev-overlay.js +26 -0
  52. package/src/dev-ready-probe.js +69 -0
  53. package/src/dev-regenerate.js +2 -2
  54. package/src/dev-reload-hold.js +49 -20
  55. package/src/dev-reload-reason.js +61 -0
  56. package/src/dev-reload-worker.js +62 -13
  57. package/src/dev-secret.js +73 -0
  58. package/src/dev-source-locations-hook.js +4 -1
  59. package/src/dev-source-locations.js +33 -2
  60. package/src/dev.js +0 -1
  61. package/src/env-files.js +124 -0
  62. package/src/file-storage.js +2 -2
  63. package/src/foreign-imports.js +228 -0
  64. package/src/form-action-identity.js +52 -25
  65. package/src/form-dispatch.js +140 -65
  66. package/src/html-cache.js +35 -4
  67. package/src/importmap.js +2 -2
  68. package/src/instrumentation.js +59 -3
  69. package/src/js-scan.js +5 -4
  70. package/src/jwe.js +155 -0
  71. package/src/lazy-components.js +166 -0
  72. package/src/listener-core.js +4 -2
  73. package/src/lit-hints.js +172 -0
  74. package/src/middleware-load.js +248 -0
  75. package/src/module-graph.js +73 -47
  76. package/src/module-side-effect.js +284 -0
  77. package/src/public-files.js +73 -0
  78. package/src/redirect-location.js +19 -0
  79. package/src/redirects.js +9 -1
  80. package/src/route-frontier.js +151 -0
  81. package/src/route-report.js +130 -0
  82. package/src/router.js +120 -13
  83. package/src/session.js +32 -10
  84. package/src/ssr/client-router-flag.js +1 -1
  85. package/src/ssr/document.js +37 -13
  86. package/src/ssr/head.js +78 -23
  87. package/src/ssr/preloads.js +31 -45
  88. package/src/ssr/render.js +241 -55
  89. package/src/ssr/responses.js +51 -4
  90. package/src/ssr/shipped-modules.js +22 -16
  91. package/src/testing.d.ts +31 -2
  92. package/src/testing.js +80 -17
  93. package/src/thenable-params.js +26 -0
  94. package/src/vendor/jspm.js +12 -1
  95. package/src/vendor/pins.js +9 -8
  96. package/src/vendor/scanner.js +14 -9
  97. package/src/vendor.js +5 -3
  98. package/webjs-config.schema.json +1 -6
  99. package/src/component-elision.js +0 -1828
  100. package/src/elision-differential.js +0 -101
  101. package/src/elision-report.js +0 -193
package/src/dev/serve.js CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Serving a request once the dev/prod handler has decided what it is: the
3
3
  * framework's own static files, an app source module (with TypeScript stripped
4
- * and elision applied), and the per-segment middleware chain around a route.
4
+ * and lazy imports deferred), and the per-segment middleware chain around a route.
5
5
  *
6
6
  * Split off handler.js, which was 1460 lines. The seam is real rather than
7
7
  * arithmetic: nothing here builds or configures the handler, and the handler
8
8
  * half calls into it at exactly three points.
9
9
  */
10
10
  import { stat, readFile } from 'node:fs/promises';
11
- import { join, extname, resolve, relative, sep } from 'node:path';
11
+ import { join, extname, resolve, relative, sep, dirname } from 'node:path';
12
12
 
13
13
  import { matchPage, matchApi } from '../router.js';
14
14
  import { ssrPage, ssrNotFound } from '../ssr.js';
@@ -26,7 +26,7 @@ import { maybeRegenerate } from '../dev-regenerate.js';
26
26
  import { stripTypeScript } from '../ts-strip.js';
27
27
  import { serveDownloadedBundle } from '../vendor.js';
28
28
  import { resolveImport } from '../module-graph.js';
29
- import { elideImportsFromSource, deferLazyImportsFromSource } from '../component-elision.js';
29
+ import { deferLazyImportsFromSource } from '../lazy-components.js';
30
30
  import { annotateSourceLocations, isSourceLocationCandidate, sourceLocationFile } from '../dev-source-locations.js';
31
31
  import { basePath } from '../importmap.js';
32
32
  import { withBasePath } from '../base-path.js';
@@ -35,7 +35,12 @@ import { toUrlPath } from '../ssr/preloads.js';
35
35
  import { BUFFERED_MARKER } from '../conditional-get.js';
36
36
  import { MIME, TS_CACHE_MAX, exists, reloadClientJs, reloadWorkerJs } from './helpers.js';
37
37
  import { devImport } from '../dev-import.js';
38
+ import { versionImportSpecifiers } from './specifier-versions.js';
39
+ import { devServiceWorkerResponse } from './dev-service-worker.js';
38
40
  import { runMetadataRoute } from '../metadata-routes.js';
41
+ import { findDecorator, decoratorReport } from '../lit-hints.js';
42
+ import { loadMiddlewareFile } from '../middleware-load.js';
43
+ import { publicFilePath, resolvePublicFile } from '../public-files.js';
39
44
 
40
45
  /**
41
46
  * Serve framework-internal static assets that depend on NEITHER the whole-app
@@ -140,11 +145,9 @@ export async function tryServeFrameworkStatic(path, method, { coreDir, appDir, d
140
145
  }
141
146
 
142
147
  /**
143
- * Serve `/public/*`, plus a small set of ROOT assets that must serve at the
144
- * site root even though they live under public/. A service worker registered at
145
- * /sw.js scopes to the origin root, so it MUST serve at / (not /public/sw.js),
146
- * and so must its offline fallback. Same remap shape as the /favicon.ico
147
- * special-case. (#830)
148
+ * Serve a file from `public/` at the URL root (public-files.js): `public/a.css`
149
+ * is `/a.css`, `public/sw.js` is `/sw.js` (so a service worker scopes to the
150
+ * origin root, #830), `public/favicon.ico` is `/favicon.ico`.
148
151
  *
149
152
  * Extracted (#1397) so the dev pre-`ensureReady()` call site and the
150
153
  * `handleCore` one share ONE implementation, and the containment guard below
@@ -154,50 +157,42 @@ export async function tryServeFrameworkStatic(path, method, { coreDir, appDir, d
154
157
  * @param {{ appDir: string, dev: boolean, versioned?: boolean, regenerateRules: any[] }} ctx
155
158
  * @returns {Promise<Response|null>} a Response, or null when the path is not a
156
159
  * public asset OR the file does not exist, in both of which cases the caller
157
- * must fall through to the rest of the pipeline (a missing `/public/x.png`
158
- * 404s through normal routing today, and that must not change). A containment
159
- * rejection returns a 404 Response and short-circuits.
160
+ * must fall through to the rest of the pipeline (a missing `/x.png`
161
+ * 404s through normal routing). A path that escapes `public/` is simply not
162
+ * a public file and falls through the same way.
160
163
  */
161
164
  export async function tryServePublicAsset(path, ctx) {
162
- const { appDir, dev, versioned, regenerateRules } = ctx;
163
- const ROOT_ASSETS = { '/sw.js': '/public/sw.js', '/offline.html': '/public/offline.html' };
164
- if (!(path.startsWith('/public/') || path === '/favicon.ico' || path in ROOT_ASSETS)) return null;
165
- const p = path === '/favicon.ico' ? '/public/favicon.ico' : (ROOT_ASSETS[path] || path);
166
- const abs = join(appDir, p);
167
- // Containment check. `join` normalises `..` segments, so a path
168
- // like `/public/..%2Fsecret/x.svg` decodes to `/public/../secret/
169
- // x.svg` and `join(appDir, ...)` resolves it to `appDir/secret/
170
- // x.svg`. The resulting `abs` could be inside `appDir` but OUTSIDE
171
- // `appDir/public/`, exposing files the user reasonably thought were
172
- // private under their non-public directories. Reject anything
173
- // that doesn't stay under `appDir/public/` (and the favicon
174
- // exception, which is already validated above).
175
- // The live vector encodes the SLASH, not the dots: the WHATWG URL
176
- // parser decodes `%2E%2E` and normalises the dot segment away, so a
177
- // `/public/%2E%2E/x` request arrives here as plain `/x` and never
178
- // enters this branch. `..%2F` survives parsing intact and does.
179
- const publicRoot = join(appDir, 'public') + sep;
180
- if (!abs.startsWith(publicRoot)) {
181
- return new Response(null, { status: 404 });
182
- }
165
+ const { appDir, dev, versioned, regenerateRules, publicFiles } = ctx;
166
+ // Under `webjs dev` the root worker is a kill switch (#1689), whether or not
167
+ // the app ships one: a caching worker on a dev origin serves the edit before
168
+ // last, and one a `webjs start` on the same port left behind is cleared too.
169
+ if (dev && path === '/sw.js') return devServiceWorkerResponse();
170
+ // `public/` is served at the URL ROOT (Next.js parity): `public/logo.png`
171
+ // is `/logo.png`. `publicFilePath` keeps the containment guard: `join`
172
+ // normalises `..`, so a `/..%2Fsecret/x.svg` that survived URL parsing
173
+ // (the WHATWG parser normalises a decoded `%2E%2E` away, but not `..%2F`)
174
+ // would otherwise resolve outside `public/`. A path escaping it is not a
175
+ // public file and falls through to the routes.
176
+ if (!publicFilePath(appDir, path)) return null;
183
177
  // On-request regeneration (#967): in dev, if a `webjs.dev.regenerate` rule
184
178
  // matches this output and it is stale (a source is newer, or it is missing),
185
179
  // rebuild it to completion BEFORE serving, so a newly added utility class is
186
180
  // never served stale. No-op when no rule matches or the output is fresh, and
187
181
  // never runs in prod (rules are empty there). This replaces the fragile
188
182
  // `tailwindcss --watch` that could die mid-session and serve stale CSS.
189
- if (dev && regenerateRules.length) {
190
- await maybeRegenerate(appDir, p.replace(/^\/+/, ''), regenerateRules);
183
+ if (dev && regenerateRules && regenerateRules.length) {
184
+ await maybeRegenerate(appDir, `public${path}`, regenerateRules);
191
185
  }
186
+ const abs = resolvePublicFile(appDir, path, { dev, index: publicFiles });
187
+ if (!abs) return null;
192
188
  // A `?v=<hash>` public asset is content-addressed -> immutable (#243).
193
- if (await exists(abs)) {
194
- const res = await fileResponse(abs, { dev, immutable: versioned });
195
- // A worker served below its registration path only controls that subtree
196
- // unless the response opts it up to the root scope. (#830)
197
- if (path === '/sw.js') res.headers.set('Service-Worker-Allowed', '/');
198
- return res;
199
- }
200
- return null;
189
+ const res = await fileResponse(abs, { dev, immutable: versioned });
190
+ // A worker served below its registration path only controls that subtree
191
+ // unless the response opts it up to the root scope. Served at the root
192
+ // already, so this only matters to a worker under a subdirectory, but it is
193
+ // harmless and keeps a registration with `scope: '/'` working. (#830)
194
+ if (path === '/sw.js') res.headers.set('Service-Worker-Allowed', '/');
195
+ return res;
201
196
  }
202
197
 
203
198
  export async function handleCore(req, ctx) {
@@ -243,12 +238,6 @@ export async function handleCore(req, ctx) {
243
238
  return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError, allowedOrigins, logger);
244
239
  }
245
240
 
246
- // Static: /public/*, the #830 root remaps, and /favicon.ico. In dev this
247
- // already ran before ensureReady() (#1397); this stays the LIVE path in prod
248
- // and the fallback in dev, and both call sites share one implementation.
249
- const publicResp = await tryServePublicAsset(path, { appDir, dev, versioned, regenerateRules: state.regenerateRules });
250
- if (publicResp) return publicResp;
251
-
252
241
  // User source modules (served as ES modules, with action-file rewriting).
253
242
  //
254
243
  // Authorization gate: only files reachable from a browser-bound entry
@@ -277,7 +266,11 @@ export async function handleCore(req, ctx) {
277
266
  // them by path (the import rewrites to an RPC stub at request time).
278
267
  // In test mode any app file is servable (see the `state.testMode` note
279
268
  // above); otherwise the file must be in the browser-bound module graph.
280
- const inGraph = state.testMode || (state.browserBoundFiles && state.browserBoundFiles.has(abs));
269
+ // A route-table file (page, layout, boundary) is refused by PATH, test mode
270
+ // included: it runs only on the server (#1686), so its source never leaves.
271
+ const isRouteModule = !!(state.routeModuleFiles && state.routeModuleFiles.has(abs));
272
+ const inGraph = !isRouteModule
273
+ && (state.testMode || (state.browserBoundFiles && state.browserBoundFiles.has(abs)));
281
274
  // Containment: `abs` must be appDir itself or genuinely UNDER it. The
282
275
  // trailing `sep` (matching the public-asset branch) stops a `..` path that
283
276
  // resolves to a sibling sharing the appDir name-prefix (`/x/app` ->
@@ -319,11 +312,10 @@ export async function handleCore(req, ctx) {
319
312
  });
320
313
  }
321
314
  // TypeScript source: strip types via Node 24+'s built-in, cache by mtime.
322
- // Both module paths also strip side-effect imports of display-only
323
- // components so the browser never downloads their JS.
324
- const elideOpts = {
315
+ // Both module paths also defer side-effect imports of `static lazy`
316
+ // components to the lazy loader.
317
+ const serveOpts = {
325
318
  moduleGraph: state.moduleGraph,
326
- elidableComponents: state.elidableComponents,
327
319
  // A browser test imports the component it tests and expects it defined
328
320
  // when the import resolves, so the test handler serves every import as
329
321
  // written (#1524).
@@ -332,14 +324,17 @@ export async function handleCore(req, ctx) {
332
324
  // Dev source locations (#1499): annotate the served module's `html`
333
325
  // templates exactly as the SSR load hook annotates the server's copy.
334
326
  sourceLocations: state.sourceLocations === true,
327
+ // Browser module versions (#1689): an import of a module a dev edit
328
+ // bumped is served with `?t=<n>`, so a hot re-import loads it fresh.
329
+ versionOf: dev && state.browserVersions ? (f) => state.browserVersions.versionOf(f) : undefined,
335
330
  };
336
331
  // A `?v=<hash>` app-module request is content-addressed -> immutable
337
332
  // (#243); an un-fingerprinted request keeps the 1h fallback.
338
333
  if (/\.m?ts$/.test(abs)) {
339
- return tsResponse(abs, dev, elideOpts, state.tsCache, versioned, reportDevError);
334
+ return tsResponse(abs, dev, serveOpts, state.tsCache, versioned, reportDevError);
340
335
  }
341
336
  if (/\.m?js$/.test(abs)) {
342
- return jsModuleResponse(abs, dev, elideOpts, versioned);
337
+ return jsModuleResponse(abs, dev, serveOpts, versioned, reportDevError);
343
338
  }
344
339
  return fileResponse(abs, { dev, immutable: versioned });
345
340
  }
@@ -359,6 +354,17 @@ export async function handleCore(req, ctx) {
359
354
  }
360
355
  }
361
356
 
357
+ // Static files from `public/`, served at the URL root (Next.js parity), after
358
+ // the app's own source modules and before every route, so a public file
359
+ // beats a dynamic route at the same url. One at the url of a static page, a
360
+ // route.ts or a metadata route is refused at boot (no-public-route-conflict).
361
+ // In dev this already ran before ensureReady() (#1397); this stays the LIVE
362
+ // path in prod and the fallback in dev, both sharing one implementation.
363
+ if (method === 'GET' || method === 'HEAD') {
364
+ const publicResp = await tryServePublicAsset(path, { appDir, dev, versioned, regenerateRules: state.regenerateRules, publicFiles: state.publicFiles });
365
+ if (publicResp) return method === 'HEAD' ? new Response(null, { status: publicResp.status, headers: publicResp.headers }) : publicResp;
366
+ }
367
+
362
368
  // Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
363
369
  if (method === 'GET' && state.routeTable.metadataRoutes) {
364
370
  const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path)
@@ -390,11 +396,16 @@ export async function handleCore(req, ctx) {
390
396
  // A handler that throws is recorded in the dev status as a `route` error
391
397
  // HERE, before a root middleware wrapping `next()` reports the same error
392
398
  // as its own (#1664). The rethrow keeps the response path unchanged.
393
- const handler = recordDevStatus
394
- ? async () => {
395
- try { return await handleApi(api.route, api.params, req, dev); } catch (e) { recordDevStatus(e, req, 'route'); throw e; }
399
+ // The same throw is reported to onError / onRequestError as a `route`
400
+ // error (reportError dedupes, so the top-level catch does not report it
401
+ // again under a vaguer phase).
402
+ const handler = async () => {
403
+ try { return await handleApi(api.route, api.params, req, dev); } catch (e) {
404
+ if (recordDevStatus) recordDevStatus(e, req, 'route');
405
+ if (reportError) reportError(e, req, 'route', routePathOf(appDir, api.route.file));
406
+ throw e;
396
407
  }
397
- : () => handleApi(api.route, api.params, req, dev);
408
+ };
398
409
  return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
399
410
  }
400
411
 
@@ -426,10 +437,8 @@ export async function handleCore(req, ctx) {
426
437
  const ssrOpts = {
427
438
  dev, appDir, moduleGraph: state.moduleGraph,
428
439
  serverFiles: state.actionIndex.fileToHash,
429
- elidableComponents: state.elidableComponents,
430
440
  lazyComponentFiles: state.lazyComponentFiles,
431
- inertRouteModules: state.inertRouteModules,
432
- importOnlyRouteModules: state.importOnlyRouteModules,
441
+ routeFrontiers: state.routeFrontiers,
433
442
  notFoundFile: state.routeTable.notFound,
434
443
  // Root-only boundaries (#848): the app-wide catch-all error page and the
435
444
  // unmatched-anywhere 404, rendered by ssr.js when a nested boundary is
@@ -446,7 +455,7 @@ export async function handleCore(req, ctx) {
446
455
  cspEnabled,
447
456
  // onError sink (issue #239): a page render error that becomes a 500 is
448
457
  // reported to the APM hook with the active request's correlation id.
449
- onError: reportError ? (e) => reportError(e, req, 'ssr') : undefined,
458
+ onError: reportError ? (e) => reportError(e, req, 'ssr', routePathOf(appDir, page.route.file)) : undefined,
450
459
  // Dev error overlay (#264): a render crash pushes a frame to the open
451
460
  // tab. Dev-only (reportDevError early-returns in prod), so no source
452
461
  // leaks. Distinct from onError (the APM sink), which always fires.
@@ -478,14 +487,27 @@ export async function handleCore(req, ctx) {
478
487
  // still current for the page the user is looking at (the #893 gap the
479
488
  // retention exists to close). Dev-only, and skipped for a prefetch,
480
489
  // which is not the user's view of anything.
490
+ //
491
+ // A render that reported a NEW error for this url is marked
492
+ // `x-webjs-dev-error: 1` (#1689), so the client router's in-place
493
+ // refresh leaves the last good page on screen under the overlay
494
+ // instead of applying the error page, and the fixing edit's refresh
495
+ // applies in place (Next's posture).
481
496
  const handler = dev && !isPrefetch
482
497
  ? async () => {
483
498
  const before = state.lastDevError;
484
- const resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
499
+ let resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
485
500
  if (
486
501
  before && state.lastDevError === before
487
502
  && before.kind === 'render' && before.url === devErrorUrl
488
503
  ) state.lastDevError = null;
504
+ const after = state.lastDevError;
505
+ if (after && after !== before && after.kind === 'render' && after.url === devErrorUrl) {
506
+ try { resp.headers.set('x-webjs-dev-error', '1'); } catch {
507
+ resp = new Response(resp.body, resp);
508
+ resp.headers.set('x-webjs-dev-error', '1');
509
+ }
510
+ }
489
511
  return resp;
490
512
  }
491
513
  : () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
@@ -569,6 +591,17 @@ export async function handleCore(req, ctx) {
569
591
  return resp;
570
592
  }
571
593
 
594
+ /**
595
+ * The route file's directory as Next's `onRequestError` context names it,
596
+ * `/app/blog/[slug]` for `app/blog/[slug]/page.ts`.
597
+ * @param {string} appDir
598
+ * @param {string} file
599
+ */
600
+ export function routePathOf(appDir, file) {
601
+ const rel = relative(join(appDir, 'app'), dirname(file)).split(sep).join('/');
602
+ return rel ? `/app/${rel}` : '/app';
603
+ }
604
+
572
605
  /** @param {Request} req @param {string} path */
573
606
  export function wantsJson(req, path) {
574
607
  const accept = req.headers.get('accept') || '';
@@ -591,10 +624,15 @@ export async function runWithSegmentMiddleware(req, files, terminal, dev) {
591
624
  const handlers = [];
592
625
  for (const f of files) {
593
626
  try {
594
- const mod = await devImport(f, dev);
595
- if (typeof mod.default === 'function') handlers.push(mod.default);
596
- } catch {
597
- // Bad middleware file: skip; top-level error handler will catch real problems.
627
+ handlers.push(await loadMiddlewareFile(f, dev));
628
+ } catch (e) {
629
+ // FAIL CLOSED: a segment middleware usually guards its subtree (auth,
630
+ // a paywall), so one that cannot load must never let the request
631
+ // through unguarded. Log the cause and answer 500.
632
+ console.error(`[webjs] segment middleware ${f} failed to load; answering 500 rather than skipping the guard:`, e);
633
+ return new Response(dev ? `[webjs] middleware ${f} failed to load: ${/** @type {Error} */ (e).message}\n` : 'Server error', {
634
+ status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' },
635
+ });
598
636
  }
599
637
  }
600
638
  let i = 0;
@@ -606,43 +644,6 @@ export async function runWithSegmentMiddleware(req, files, terminal, dev) {
606
644
  return next();
607
645
  }
608
646
 
609
- /**
610
- * Root-middleware filename candidates, in resolution order.
611
- *
612
- * Every other routing convention accepts all four extensions (the router
613
- * matches on the STEM, so `app/<segment>/middleware.ts` has always worked),
614
- * and both the scaffold and the dev supervisor write / watch `middleware.ts`.
615
- * This lookup used to be the single literal `middleware.js`, so a root
616
- * `middleware.ts` was silently never loaded: no error, no warning, the app
617
- * just ran with no global middleware. TypeScript is the documented default
618
- * for an app, so that was the common case, and it went unnoticed because the
619
- * failure is invisible (a missing middleware looks exactly like an app that
620
- * has none). `.ts` is tried first to match the dev supervisor's order.
621
- */
622
- const ROOT_MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
623
-
624
- /**
625
- * Load the optional top-level `middleware.{ts,js,mts,mjs}`.
626
- * @param {string} appDir
627
- * @param {boolean} dev
628
- * @param {import('../logger.js').Logger} logger
629
- */
630
- export async function loadMiddleware(appDir, dev, logger) {
631
- let file = null;
632
- for (const name of ROOT_MIDDLEWARE_FILES) {
633
- const candidate = join(appDir, name);
634
- if (await exists(candidate)) { file = candidate; break; }
635
- }
636
- if (!file) return null;
637
- try {
638
- const mod = await devImport(file, dev);
639
- return typeof mod.default === 'function' ? mod.default : null;
640
- } catch (e) {
641
- logger.error('failed to load root middleware', { file, err: String(e) });
642
- return null;
643
- }
644
- }
645
-
646
647
  /**
647
648
  * Read a file and return a Response with appropriate caching.
648
649
  * Dev: no-cache (always revalidate).
@@ -717,18 +718,19 @@ export async function fileResponse(abs, opts) {
717
718
  *
718
719
  * @param {string} abs
719
720
  * @param {boolean} dev
720
- * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, lazyComponentFiles?: Map<string, string[]>, appDir: string }} elideOpts
721
+ * @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} serveOpts
721
722
  * @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
722
723
  * serve `immutable` (1 year) instead of the 1h fallback. Dev stays `no-cache`.
724
+ * @param {(error: unknown, info?: object) => void} [reportDevError] dev overlay hook
723
725
  */
724
- export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
726
+ export async function jsModuleResponse(abs, dev, serveOpts, immutable, reportDevError) {
725
727
  let source;
726
728
  try { source = await readFile(abs, 'utf8'); }
727
729
  catch { return new Response('Not found', { status: 404 }); }
728
- let code = elideImportsFromSource(
729
- annotateServedModule(source, abs, elideOpts), abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
730
- );
731
- code = deferLazyImports(code, abs, elideOpts);
730
+ const refused = decoratorRefusal(source, abs, dev, serveOpts && serveOpts.appDir, reportDevError);
731
+ if (refused) return refused;
732
+ let code = annotateServedModule(source, abs, serveOpts);
733
+ code = deferLazyImports(code, abs, serveOpts);
732
734
  // Version same-origin relative import specifiers so the URL the browser
733
735
  // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
734
736
  // A no-op in dev (fingerprinting disabled).
@@ -743,6 +745,44 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
743
745
  return new Response(code, { status: 200, headers });
744
746
  }
745
747
 
748
+ /**
749
+ * Refuse an app module that carries a decorator (`@customElement`,
750
+ * `@property`, `@state`, `@query`, ...) with a 500 naming the decorator and
751
+ * its WebJs spelling, the same shape a TypeScript strip failure answers with.
752
+ * Neither Node's type stripper nor the browser runs decorators, so serving the
753
+ * module would only move the failure to a bare `SyntaxError` in the browser
754
+ * console. Scoped to the app's own files outside node_modules; the scan runs
755
+ * only when the source contains an `@` at all.
756
+ *
757
+ * @param {string} code the served source (stripped for TypeScript)
758
+ * @param {string} abs
759
+ * @param {boolean} dev
760
+ * @param {string | undefined} appDir
761
+ * @param {((error: unknown, info?: object) => void) | undefined} reportDevError
762
+ * @returns {Response | null}
763
+ */
764
+ function decoratorRefusal(code, abs, dev, appDir, reportDevError) {
765
+ if (!code.includes('@')) return null;
766
+ if (appDir && !abs.startsWith(appDir + sep)) return null;
767
+ if (abs.includes(sep + 'node_modules' + sep)) return null;
768
+ const hit = findDecorator(code);
769
+ if (!hit) return null;
770
+ const { message, fix } = decoratorReport(hit);
771
+ const where = abs + ':' + hit.line + ':' + hit.column;
772
+ const err = new SyntaxError('Decorator @' + hit.name + ' in ' + where);
773
+ // eslint-disable-next-line no-console
774
+ console.error('[webjs] ' + err.message + '. ' + fix);
775
+ reportDevError?.(err, { kind: 'ts-strip', file: abs, line: hit.line, column: hit.column, hint: message + ' ' + fix });
776
+ const msg = dev
777
+ ? '[webjs] ' + err.message + '\n\n' + message + '\n' + fix +
778
+ '\nThe no-non-erasable-typescript check rule reports this at edit time. Docs: https://webjs.dev/docs/troubleshooting#decorators'
779
+ : '[webjs] server error transforming a module response. Check server logs.';
780
+ return new Response('/* ' + msg.replace(/\*\//g, '*\\/') + ' */', {
781
+ status: 500,
782
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
783
+ });
784
+ }
785
+
746
786
  /**
747
787
  * Serve a side-effect import of a `static lazy` component as a lazy-loader
748
788
  * registration (#1524). The URL the loader imports is the one the boot script
@@ -753,14 +793,19 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
753
793
  *
754
794
  * @param {string} code
755
795
  * @param {string} abs
756
- * @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} opts
796
+ * @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string, versionOf?: (abs: string) => number }} opts
757
797
  * @returns {string}
758
798
  */
759
799
  function deferLazyImports(code, abs, opts) {
760
- if (!opts.lazyComponentFiles || opts.lazyComponentFiles.size === 0) return code;
761
- const bp = basePath();
762
- const urlFor = (file) => withAssetHash(withBasePath(toUrlPath(file, opts.appDir), bp), bp);
763
- return deferLazyImportsFromSource(code, abs, opts.moduleGraph, opts.lazyComponentFiles, resolveImport, opts.appDir, urlFor);
800
+ if (opts.lazyComponentFiles && opts.lazyComponentFiles.size > 0) {
801
+ const bp = basePath();
802
+ const urlFor = (file) => withAssetHash(withBasePath(toUrlPath(file, opts.appDir), bp), bp);
803
+ code = deferLazyImportsFromSource(code, abs, opts.moduleGraph, opts.lazyComponentFiles, resolveImport, opts.appDir, urlFor);
804
+ }
805
+ // Dev browser module versions (#1689), LAST: the lazy deferral matches
806
+ // the specifiers as written, so it must see them unversioned.
807
+ if (opts.versionOf) code = versionImportSpecifiers(code, abs, opts.appDir, opts.versionOf);
808
+ return code;
764
809
  }
765
810
 
766
811
  /**
@@ -811,21 +856,21 @@ export async function stripTs(source, _abs) {
811
856
  /**
812
857
  * Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
813
858
  * Result is cached by mtime in the handler's own `cache` so subsequent
814
- * requests are instant; a file edit invalidates naturally. `elideOpts`
815
- * additionally strips side-effect imports of display-only components from
816
- * the served code, which is exactly why `cache` is the per-handler
859
+ * requests are instant; a file edit invalidates naturally. `serveOpts`
860
+ * additionally defers side-effect imports of `static lazy` components in the
861
+ * served code, which is exactly why `cache` is the per-handler
817
862
  * `state.tsCache` and not a module-global: the cached bytes bake in this
818
- * handler's elision verdict.
863
+ * handler's lazy set.
819
864
  *
820
865
  * @param {string} abs
821
866
  * @param {boolean} dev
822
- * @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, lazyComponentFiles?: Map<string, string[]>, appDir: string }} [elideOpts]
867
+ * @param {{ moduleGraph: any, lazyComponentFiles?: Map<string, string[]>, appDir: string }} [serveOpts]
823
868
  * @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
824
869
  * @param {boolean} [immutable] true for a `?v=<hash>` content-addressed request (#243):
825
870
  * serve `immutable` (1 year) instead of the 1h fallback. The cached BODY is
826
871
  * the same bytes regardless; only the cache header varies. Dev stays `no-cache`.
827
872
  */
828
- export async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError) {
873
+ export async function tsResponse(abs, dev, serveOpts, cache, immutable, reportDevError) {
829
874
  // The body bytes are identical with or without `?v`; only the cache header
830
875
  // changes, so the per-mtime cache stays a single entry.
831
876
  const cacheControl = dev ? 'no-cache' : immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
@@ -888,16 +933,19 @@ export async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDe
888
933
  }
889
934
  throw err;
890
935
  }
891
- if (elideOpts) {
892
- code = annotateServedModule(code, abs, elideOpts);
893
- code = elideImportsFromSource(
894
- code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
895
- );
896
- code = deferLazyImports(code, abs, elideOpts);
936
+ // The stripper passes a decorator through untouched, so without this the
937
+ // browser receives the module and fails it with a bare SyntaxError that
938
+ // names neither the decorator nor the fix. Not cached, so the next request
939
+ // after the edit re-strips and re-checks.
940
+ const refused = decoratorRefusal(code, abs, dev, serveOpts && serveOpts.appDir, reportDevError);
941
+ if (refused) return refused;
942
+ if (serveOpts) {
943
+ code = annotateServedModule(code, abs, serveOpts);
944
+ code = deferLazyImports(code, abs, serveOpts);
897
945
  }
898
946
  // Version same-origin relative import specifiers so the URL the browser
899
947
  // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
900
- // A no-op in dev (fingerprinting disabled). Cached with the elision result:
948
+ // A no-op in dev (fingerprinting disabled). Cached with the lazy rewrite:
901
949
  // PROD files are static within a deploy, so the baked `?v` stays correct.
902
950
  code = versionModuleImports(code, abs);
903
951
  // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).