@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
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Rules for the line between server-only route modules and browser code
3
+ * (#1686). A page, layout or boundary runs only on the server, like a Next.js
4
+ * server component, and never loads in a browser. These rules refuse the
5
+ * shapes that would fail silently because of it: a component defined in a
6
+ * route file (its tag never upgrades), an event binding in a route template
7
+ * (its handler never runs), a browser module importing a route file (the
8
+ * import 404s), and a Node builtin reached from browser code (it can never
9
+ * resolve there).
10
+ *
11
+ * @module
12
+ */
13
+ import { builtinModules } from 'node:module';
14
+ import { relative } from 'node:path';
15
+
16
+ import { maskComments, redactToPlaceholders } from '../js-scan.js';
17
+ import { hasUseServerDirective } from './helpers.js';
18
+ import { EVENT_BINDING_RE, REACTIVE_IMPORTS, extractRenderedTags } from '../client-signals.js';
19
+
20
+ /**
21
+ * @typedef {import('./rules.js').Violation} Violation
22
+ * @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
23
+ */
24
+
25
+ const BUILTINS = new Set(builtinModules);
26
+ const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
27
+ /** A registration with a literal tag: `X.register('tag')` or `customElements.define('tag', X)`. */
28
+ const REGISTER_RE = /(?:\.register|customElements\.define)\(\s*(['"])([a-z][a-z0-9]*-[a-z0-9-]*)\1/g;
29
+ /** Lifecycle hooks that only ever run in a browser (`willUpdate` also runs during SSR, so it is not here). */
30
+ const CLIENT_ONLY_HOOKS = ['connectedCallback', 'disconnectedCallback', 'attributeChangedCallback', 'adoptedCallback',
31
+ 'firstUpdated', 'updated', 'renderFallback', 'scheduleUpdate', 'performUpdate'];
32
+ const HOOK_RE = new RegExp(`^\\s*(?:async\\s+)?(${CLIENT_ONLY_HOOKS.join('|')})\\s*\\(`, 'm');
33
+ /** A named import of a reactive primitive from core: `import { signal } from '@webjsdev/core'`. */
34
+ const CORE_NAMED_IMPORT_RE = /import\s*\{([^}]*)\}\s*from\s*['"]@webjsdev\/core(?:\/[a-z-]+)?['"]/g;
35
+ /** A `'use client'` directive in a module's prologue (comments and blank lines may precede it). */
36
+ const USE_CLIENT_RE = /^(?:\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/))*\s*(['"])use client\1\s*;?/;
37
+ const IMPORT_RE = /(?:^|[;\n])\s*(?:import|export)\s+(type\s+)?(?:[^'"`;]*?\s+from\s+)?['"]__STR_(\d+)__['"]/g;
38
+
39
+ /**
40
+ * The first event binding that sits inside a real open tag. A binding written
41
+ * as escaped text (`<button @click=...`, a docs sample) is not one: the
42
+ * nearest `<` before it must open a tag, with no `>` in between.
43
+ * @param {string} src
44
+ * @returns {{ index: number, text: string } | null}
45
+ */
46
+ function firstTagBinding(src) {
47
+ const re = new RegExp(EVENT_BINDING_RE.source, 'g');
48
+ for (const m of src.matchAll(re)) {
49
+ const lt = src.lastIndexOf('<', m.index);
50
+ if (lt === -1 || src.lastIndexOf('>', m.index) > lt) continue;
51
+ if (!/[A-Za-z]/.test(src[lt + 1] || '')) continue;
52
+ return { index: /** @type {number} */ (m.index), text: m[0] };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /** 1-based line of `index` in `src`. */
58
+ const lineOf = (src, index) => src.slice(0, index).split('\n').length;
59
+
60
+ /**
61
+ * @param {string} appDir
62
+ * @param {ScannedFile[]} files
63
+ * @param {Violation[]} violations
64
+ * @param {{ routeFiles: Set<string>, componentFiles: Set<string>, moduleGraph: Map<string, Set<string>>, shipped: Set<string> }} ctx
65
+ */
66
+ export function checkRouteModuleBoundaries(appDir, files, violations, ctx) {
67
+ const { routeFiles, componentFiles, moduleGraph, shipped } = ctx;
68
+ // Tags registered by components in `*.server.*` files: rendered on the
69
+ // server, never defined in the browser (#1686).
70
+ /** @type {Map<string, string>} tag -> the file that registers it */
71
+ const serverTags = new Map();
72
+ for (const { rel, content } of files) {
73
+ if (!SERVER_FILE_RE.test(rel) || !/extends\s+WebComponent\b/.test(content)) continue;
74
+ // --- no-component-in-use-server-file ---
75
+ // Every function export of a `'use server'` file is wrapped as an action
76
+ // (a class is a function), so a component there is neither a component
77
+ // nor a usable action.
78
+ if (hasUseServerDirective(content)) {
79
+ violations.push({
80
+ rule: 'no-component-in-use-server-file',
81
+ file: rel,
82
+ message: "This `'use server'` file defines a component. Every function export of a `'use server'` file is wrapped as a server action, and a class is a function, so the component is neither a working component nor a usable action.",
83
+ fix: "Drop `'use server'` to make it a server-only component (a component in a `.server` file without the directive renders on the server and ships nothing), or move the class to an ordinary file to make it a component that runs in the browser. Keep the actions in their own `'use server'` file.",
84
+ });
85
+ continue;
86
+ }
87
+ for (const m of maskComments(content).matchAll(REGISTER_RE)) serverTags.set(m[2], rel);
88
+ checkServerComponent(rel, content, violations);
89
+ }
90
+ for (const { abs, rel, content } of files) {
91
+ if (routeFiles.has(abs)) {
92
+ // --- no-use-client-in-route-module ---
93
+ // The Next.js way to make a page interactive. In WebJs the client part
94
+ // is a component (Lit's APIs), so the directive maps to one message
95
+ // rather than a page model neither habit predicts. In a component file
96
+ // the directive is true already and is left alone.
97
+ if (USE_CLIENT_RE.test(content)) {
98
+ violations.push({
99
+ rule: 'no-use-client-in-route-module',
100
+ file: rel,
101
+ message: "`'use client'` marks this page, layout or boundary as client code, the Next.js way. In WebJs a route module is always a server component and the client part of a page is a component, so the directive does nothing here.",
102
+ fix: "Move the interactive part into a component in its own file (`components/my-widget.ts`, a `WebComponent` using Lit's APIs: reactive properties, `@click`, the lifecycle), render its tag from this page, and drop `'use client'`. Pass server data in as properties.",
103
+ });
104
+ }
105
+ // --- no-component-in-route-module ---
106
+ if (componentFiles.has(abs)) {
107
+ violations.push({
108
+ rule: 'no-component-in-route-module',
109
+ file: rel,
110
+ message: 'This page, layout or boundary defines a component. A route module runs only on the server and never loads in the browser, so the element renders its server markup and never upgrades.',
111
+ fix: 'Move the component class into its own file (for example `components/my-widget.ts`) and import that file here.',
112
+ });
113
+ }
114
+ // --- no-event-binding-in-route-module ---
115
+ const masked = maskComments(content);
116
+ const m = firstTagBinding(masked);
117
+ if (m) {
118
+ const binding = m.text.replace(/\s*=.*$/, '');
119
+ violations.push({
120
+ rule: 'no-event-binding-in-route-module',
121
+ file: rel,
122
+ message: `\`${binding}\` (line ${lineOf(masked, m.index)}) is bound in a page, layout or boundary template. A route module renders only on the server and never hydrates, so the handler never runs (the same reason Next.js refuses an event handler in a server component).`,
123
+ fix: 'Put the interactive part in a component and render its tag here, or use a plain `<form action=${action}>` / `<a href>` that works without JavaScript.',
124
+ });
125
+ }
126
+ }
127
+ if (!shipped.has(abs) || routeFiles.has(abs)) continue;
128
+ // --- no-server-component-in-client-template ---
129
+ if (serverTags.size) {
130
+ for (const tag of extractRenderedTags(content)) {
131
+ const owner = serverTags.get(tag);
132
+ if (!owner) continue;
133
+ violations.push({
134
+ rule: 'no-server-component-in-client-template',
135
+ file: rel,
136
+ message: `This module loads in the browser and renders <${tag}>, a server-only component (${owner}). It is never defined in the browser, so it renders on the server and comes back empty after a client re-render, the reason Next.js does not let a client component render a server component.`,
137
+ fix: `Render <${tag}> from the page and pass it into this component as a child (it lands in the component's <slot>), as Next.js passes a server component to a client one as children. Or, if it needs to run in the browser, move it out of the .server file.`,
138
+ });
139
+ break;
140
+ }
141
+ }
142
+ // --- no-route-module-import-in-browser-module ---
143
+ for (const dep of moduleGraph.get(abs) || []) {
144
+ if (!routeFiles.has(dep)) continue;
145
+ violations.push({
146
+ rule: 'no-route-module-import-in-browser-module',
147
+ file: rel,
148
+ message: `This module loads in the browser but imports the route module ${relative(appDir, dep)}. Pages, layouts and boundaries run only on the server, so the import 404s in the browser and this module fails to load.`,
149
+ fix: 'Move what you need from the route module into a shared file (a helper under `lib/`, or a component) and import that from both.',
150
+ });
151
+ break;
152
+ }
153
+ // --- no-node-builtin-in-browser-module ---
154
+ const { redacted, literals } = redactToPlaceholders(content);
155
+ for (const im of redacted.matchAll(IMPORT_RE)) {
156
+ if (im[1]) continue; // `import type` is erased
157
+ const spec = literals[Number(im[2])];
158
+ if (!spec || !(spec.startsWith('node:') || BUILTINS.has(spec))) continue;
159
+ violations.push({
160
+ rule: 'no-node-builtin-in-browser-module',
161
+ file: rel,
162
+ message: `This module loads in the browser but imports \`${spec}\` (line ${lineOf(redacted, im.index + (im[0].length - im[0].trimStart().length))}), a Node builtin that cannot resolve there, so the module fails to load.`,
163
+ fix: 'Do the work on the server: read it in the page (pages run only on the server) and pass the result in as a property, or put it behind a `\'use server\'` action in a `.server.ts` file.',
164
+ });
165
+ break;
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * `no-interactivity-in-server-component`: a component in a `*.server.*` file
172
+ * renders on the server only and is never defined in the browser (#1686), so
173
+ * an event binding, a reactive primitive or a client-only lifecycle hook in it
174
+ * never runs.
175
+ * @param {string} rel @param {string} content @param {Violation[]} violations
176
+ */
177
+ function checkServerComponent(rel, content, violations) {
178
+ const masked = maskComments(content);
179
+ /** @type {string|null} */
180
+ let what = null;
181
+ const binding = firstTagBinding(masked);
182
+ if (binding) what = `\`${binding.text.replace(/\s*=.*$/, '')}\` (line ${lineOf(masked, binding.index)})`;
183
+ if (!what) {
184
+ for (const m of masked.matchAll(CORE_NAMED_IMPORT_RE)) {
185
+ const name = m[1].split(',').map((x) => x.trim().split(/\s+as\s+/)[0].replace(/^type\s+/, '')).find((x) => REACTIVE_IMPORTS.has(x));
186
+ if (name) { what = `\`${name}\``; break; }
187
+ }
188
+ }
189
+ if (!what) {
190
+ const hook = HOOK_RE.exec(masked);
191
+ if (hook) what = `\`${hook[1]}()\``;
192
+ }
193
+ if (!what) return;
194
+ violations.push({
195
+ rule: 'no-interactivity-in-server-component',
196
+ file: rel,
197
+ message: `This component is in a \`.server\` file, so it renders on the server only and is never defined in the browser, but it uses ${what}, which only runs in a browser.`,
198
+ fix: 'Move the component out of the `.server` file (a component in an ordinary file always ships and runs in the browser), and keep its server data access in a `\'use server\'` action or pass the data in from the page.',
199
+ });
200
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Rule: `no-conflicting-routes` (#1686). Two files that resolve to ONE url, the
3
+ * two conflicts Next.js fails the build on:
4
+ *
5
+ * - two pages, typically in different route groups (`app/(marketing)/page.ts`
6
+ * and `app/(app)/page.ts` both serve `/`), or the same dynamic position
7
+ * spelled with different param names (`[id]` beside `[slug]`);
8
+ * - a `page` and a `route` handler at the same url.
9
+ *
10
+ * WebJs used to accept either silently and serve whichever the matcher met
11
+ * first, so one of the two files was dead code that looked live.
12
+ *
13
+ * @module
14
+ */
15
+ import { relative, sep } from 'node:path';
16
+ import { buildRouteTable } from '../router.js';
17
+
18
+ /**
19
+ * @param {string} appDir
20
+ * @param {import('./rules.js').Violation[]} violations
21
+ */
22
+ export async function checkNoConflictingRoutes(appDir, violations) {
23
+ let table;
24
+ try { table = await buildRouteTable(appDir); } catch { return; }
25
+ const rel = (f) => relative(appDir, f).split(sep).join('/');
26
+ /** @type {Map<string, string[]>} url pattern -> page files */
27
+ const pages = new Map();
28
+ for (const p of table.pages || []) {
29
+ const key = p.pattern.source;
30
+ if (!pages.has(key)) pages.set(key, []);
31
+ pages.get(key).push(rel(p.file));
32
+ }
33
+ for (const files of pages.values()) {
34
+ if (files.length < 2) continue;
35
+ const [first, ...rest] = files.sort();
36
+ for (const file of rest) {
37
+ violations.push({
38
+ rule: 'no-conflicting-routes',
39
+ file,
40
+ message: `This page resolves to the same URL as ${first} (route groups and param names do not change a URL), so only one of them can ever be served. Next.js refuses this at build time.`,
41
+ fix: 'Give one of the pages a different path, merge the two, or, for a dynamic segment, use one param name at that position.',
42
+ });
43
+ }
44
+ }
45
+ for (const api of table.apis || []) {
46
+ const page = pages.get(api.pattern.source);
47
+ if (!page) continue;
48
+ violations.push({
49
+ rule: 'no-conflicting-routes',
50
+ file: rel(api.file),
51
+ message: `This route handler shares its URL with the page ${page[0]}, so one of them is never reached. Next.js refuses a page and a route at the same path.`,
52
+ fix: 'Move the handler under app/api/ (or another path no page uses), or turn the page into the handler.',
53
+ });
54
+ }
55
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Routing and template rules: what a route handler may throw, what a non-root
2
+ * Routing and template rules: what a non-root
3
3
  * layout may emit, and which holes a template may carry.
4
4
  *
5
5
  * Lifted verbatim out of checkConventions, which held all twenty rules inline
@@ -9,83 +9,63 @@
9
9
  import {
10
10
  isServerActionFile, isComponentFile,
11
11
  } from './helpers.js';
12
+ import { buildRouteTable } from '../router.js';
13
+ import { maskComments, redactStringsAndTemplates } from '../js-scan.js';
14
+ import { validateAppLayout } from '../app-layout.js';
15
+ import { buildPublicIndex } from '../public-files.js';
16
+ import { findJsxIsm } from '@webjsdev/core/server';
17
+ import { htmlTemplateStrings } from '../dev-source-locations.js';
12
18
 
13
19
  /**
14
- * @typedef {import('./rules.js').Violation} Violation
15
- * @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
20
+ * Rule: `no-jsx-attributes`.
21
+ *
22
+ * `className=`, `htmlFor=`, or a capitalised component tag (`<Link>`) in an
23
+ * `html` template: JSX that HTML silently ignores. Both renderers refuse it
24
+ * when the template first compiles; this reports it ahead of time, with the
25
+ * line the template starts on.
26
+ *
27
+ * @param {ScannedFile[]} files
28
+ * @param {import('./rules.js').Violation[]} violations
16
29
  */
17
-
30
+ export function checkNoJsxAttributes(files, violations) {
31
+ for (const { rel, content } of files) {
32
+ for (const t of htmlTemplateStrings(content)) {
33
+ const ism = findJsxIsm(t.strings);
34
+ if (!ism) continue;
35
+ violations.push({
36
+ rule: 'no-jsx-attributes',
37
+ file: rel,
38
+ message: ism.kind === 'attr'
39
+ ? `The html template at line ${t.line} uses \`${ism.name}=\`, a JSX spelling HTML reads as an unknown attribute, so it does nothing (the render throws).`
40
+ : `The html template at line ${t.line} uses \`<${ism.name}>\`, a JSX component HTML lowercases into an unknown element (the render throws).`,
41
+ fix: ism.kind === 'attr'
42
+ ? `Write \`${ism.name === 'className' ? 'class' : 'for'}=\` instead.`
43
+ : `Render the custom element's registered tag (with a hyphen, such as \`<my-${ism.name.toLowerCase()}>\`), or a plain element such as \`<a href>\` for a link.`,
44
+ });
45
+ }
46
+ }
47
+ }
18
48
 
19
49
  /**
20
- * Rule: `no-redirect-in-api-route`.
50
+ * Rules fed by the route table: `no-page-route-conflict` and the other file
51
+ * arrangements `validateAppLayout` refuses at boot. The check reports exactly
52
+ * what boot would refuse, so a clean `webjs check` means a clean boot.
21
53
  *
22
- * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
23
- * @param {Violation[]} violations accumulator the rule pushes onto
24
- * @returns {void}
54
+ * @param {string} appDir
55
+ * @param {import('./rules.js').Violation[]} violations
25
56
  */
26
- export function checkNoRedirectInApiRoute(files, violations) {
27
- // --- Rule: no-redirect-in-api-route ---
28
- // `redirect()` from `@webjsdev/core` throws a control-flow signal designed
29
- // for the SSR page renderer. In a `route.ts` API handler it goes uncaught
30
- // and produces a 500. API handlers must use `Response.redirect(url, 303)`
31
- // instead. Page functions, layouts, and server actions may still use
32
- // `redirect()` (caught by the SSR / action pipeline).
33
- {
34
- const ROUTE_FILE = /(?:^|\/)route\.m?[jt]s$/;
35
- for (const { rel, scan } of files) {
36
- if (!ROUTE_FILE.test(rel)) continue;
37
- // `redirect` reaches the route file in one of two statically-visible ways:
38
- // 1. A NAMED import from `@webjsdev/core` (possibly aliased):
39
- // `import { redirect }`, `import { redirect as r }`, `import { …, redirect, … }`.
40
- // 2. A NAMESPACE import then a member call:
41
- // `import * as core from '@webjsdev/core'` then `core.redirect(...)`.
42
- // The named case flags a bare `redirect(` call; the namespace case flags
43
- // `<ns>.redirect(`. `Response.redirect(` and any other `obj.redirect(` are
44
- // the standard API and stay fine. A `redirect()` thrown inside a
45
- // '`use server`' action the route calls DIRECTLY (an uncaught 500) needs
46
- // cross-file analysis and is left to the AST rework (#753).
47
- const namedM = /\bimport\s+\{[^}]*\bredirect\b(?:\s+as\s+(\w+))?\s*[^}]*\}\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
48
- const nsM = /\bimport\s+\*\s+as\s+(\w+)\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
49
- // A file can carry BOTH a named `redirect` import AND a namespace import,
50
- // so check every matcher independently (not mutually exclusive): a named
51
- // import means a bare `<localName>(`, a namespace import means
52
- // `<ns>.redirect(`. The `member` flag distinguishes the two so a bare
53
- // named call can screen out `Response.redirect(` / `obj.redirect(`.
54
- /** @type {Array<{ re: RegExp, member: boolean }>} */
55
- const matchers = [];
56
- if (namedM) {
57
- const localName = namedM[1] || 'redirect';
58
- matchers.push({ re: new RegExp(`(?<!\\.)\\b${localName}\\s*\\(`, 'g'), member: false });
59
- }
60
- if (nsM) {
61
- matchers.push({ re: new RegExp(`\\b${nsM[1]}\\.redirect\\s*\\(`, 'g'), member: true });
62
- }
63
- let flagged = false;
64
- for (const { re, member } of matchers) {
65
- if (flagged) break;
66
- let m;
67
- while ((m = re.exec(scan)) !== null) {
68
- if (!member) {
69
- // Screen out `Response.redirect(` / `someObj.redirect(` sharing the
70
- // local name: a preceding member-access dot means it is not the import.
71
- const before = scan.slice(Math.max(0, m.index - 20), m.index);
72
- if (/\w\.$/.test(before)) continue;
73
- }
74
- violations.push({
75
- rule: 'no-redirect-in-api-route',
76
- file: rel,
77
- message:
78
- `\`redirect()\` from \`@webjsdev/core\` throws a control-flow signal for the SSR page renderer; in a \`route.ts\` handler it goes uncaught and returns a 500.`,
79
- fix: `Use \`Response.redirect(url, 303)\` for external redirects, or return a 3xx Response directly. The \`redirect()\` sentinel is only valid in page functions, layouts, and server actions (where the SSR pipeline catches it).`,
80
- });
81
- flagged = true; // one violation per file is enough
82
- break;
83
- }
84
- }
85
- }
86
- }
57
+ export async function checkAppLayout(appDir, violations) {
58
+ let table;
59
+ try { table = await buildRouteTable(appDir); } catch { return; }
60
+ for (const p of validateAppLayout(appDir, table, buildPublicIndex(appDir))) violations.push(p);
87
61
  }
88
62
 
63
+ /**
64
+ * @typedef {import('./rules.js').Violation} Violation
65
+ * @typedef {{ abs: string, rel: string, content: string, scan: string }} ScannedFile
66
+ */
67
+
68
+
89
69
  /**
90
70
  * Rule: `no-interpolation-in-raw-text-element`.
91
71
  *
@@ -146,37 +126,105 @@ export function checkNoInterpolationInRawTextElement(files, violations) {
146
126
  * @param {Violation[]} violations accumulator the rule pushes onto
147
127
  * @returns {void}
148
128
  */
149
- export function checkNoServerEnvInComponents(files, violations) {
129
+ export function checkNoServerEnvInComponents(files, violations, shipped = new Set(), routeFiles = new Set()) {
150
130
  // --- Rule: no-server-env-in-components ---
151
- // Catches `process.env.X` reads in component files where X is not a
152
- // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
153
- // two categories to the browser; any other read either leaks a secret
154
- // into the SSR'd HTML or reads as undefined after hydration.
155
- {
156
- for (const { abs, rel, content } of files) {
157
- if (!isComponentFile(rel)) continue;
158
- if (isServerActionFile(abs, content)) continue;
159
-
160
- const re = /\bprocess\.env\.([A-Z][A-Z0-9_]*)\b/g;
161
- const seen = new Set();
162
- let m;
163
- while ((m = re.exec(content)) !== null) {
164
- const name = m[1];
165
- if (name.startsWith('WEBJS_PUBLIC_')) continue;
166
- if (name === 'NODE_ENV') continue;
167
- if (seen.has(name)) continue;
168
- seen.add(name);
169
- violations.push({
170
- rule: 'no-server-env-in-components',
171
- file: rel,
172
- message: `Component reads process.env.${name}; server-only env vars must not be read in components (would leak into SSR'd HTML and read as undefined after hydration)`,
173
- fix: `Either rename to WEBJS_PUBLIC_${name} if the value is intended for the browser, or read process.env.${name} in a page function / server action / middleware and pass a derived value to the component as an attribute.`,
174
- });
175
- }
131
+ // Catches `process.env.X` reads in code the BROWSER runs, where X is not a
132
+ // WEBJS_PUBLIC_* var and not NODE_ENV: every component file, plus every
133
+ // module the build ships to the browser (a util a component imports). The
134
+ // browser env shim only exposes those two categories; any other read either
135
+ // leaks a secret into the SSR'd HTML or reads as undefined after hydration.
136
+ // A page / layout / boundary function runs on the server only, so its reads
137
+ // are fine and it is not scanned. Next.js's NEXT_PUBLIC_ prefix gets a
138
+ // targeted fix, since it is the usual cause.
139
+ for (const { abs, rel, content } of files) {
140
+ if (!isComponentFile(rel) && !shipped.has(abs)) continue;
141
+ if (/\.server\.m?[jt]s$/.test(rel) || isServerActionFile(abs, content)) continue;
142
+ // Route-ness comes from the route table, never a file name (#1686).
143
+ if (!isComponentFile(rel) && routeFiles.has(abs)) continue;
144
+ const masked = maskComments(content);
145
+ const re = /\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)\b/g;
146
+ const seen = new Set();
147
+ let m;
148
+ while ((m = re.exec(masked)) !== null) {
149
+ const name = m[1];
150
+ if (name.startsWith('WEBJS_PUBLIC_')) continue;
151
+ if (name === 'NODE_ENV') continue;
152
+ if (seen.has(name)) continue;
153
+ seen.add(name);
154
+ const where = isComponentFile(rel) ? 'Component' : 'Browser-shipped module';
155
+ const nextPublic = name.startsWith('NEXT_PUBLIC_');
156
+ violations.push({
157
+ rule: 'no-server-env-in-components',
158
+ file: rel,
159
+ message: nextPublic
160
+ ? `${where} reads process.env.${name}. NEXT_PUBLIC_ is Next.js's browser prefix; WebJs exposes only WEBJS_PUBLIC_* (and NODE_ENV) to the browser, so this reads as undefined there.`
161
+ : `${where} reads process.env.${name}; server-only env vars must not be read in code the browser runs (it would leak into the SSR'd HTML and read as undefined after hydration)`,
162
+ fix: nextPublic
163
+ ? `Rename the variable to WEBJS_PUBLIC_${name.slice('NEXT_PUBLIC_'.length)} in your .env files and here.`
164
+ : `Either rename to WEBJS_PUBLIC_${name} if the value is intended for the browser, or read process.env.${name} in a page function / server action / middleware and pass a derived value to the component as an attribute.`,
165
+ });
176
166
  }
177
167
  }
178
168
  }
179
169
 
170
+ /**
171
+ * Rule: `no-next-runtime-guard`.
172
+ *
173
+ * `process.env.NEXT_RUNTIME` is set by Next.js only (to 'nodejs' or 'edge'),
174
+ * typically as `if (process.env.NEXT_RUNTIME === 'nodejs')` around an
175
+ * instrumentation `register()`. Under WebJs it is undefined, so the guarded
176
+ * code silently never runs.
177
+ *
178
+ * @param {ScannedFile[]} files
179
+ * @param {Violation[]} violations
180
+ */
181
+ export function checkNoNextRuntimeGuard(files, violations) {
182
+ for (const { rel, scan } of files) {
183
+ // `scan` blanks comments and template bodies (a docs page quoting the
184
+ // guard is not code).
185
+ const masked = scan;
186
+ if (!/\bprocess\.env\.NEXT_RUNTIME\b/.test(masked)) continue;
187
+ violations.push({
188
+ rule: 'no-next-runtime-guard',
189
+ file: rel,
190
+ message: 'Reads process.env.NEXT_RUNTIME, which only Next.js sets. Under WebJs it is undefined, so code guarded by it (an instrumentation register(), say) silently never runs.',
191
+ fix: "Drop the guard: WebJs runs instrumentation.ts register() once, on the server only. To tell the runtimes apart use process.versions.bun (Bun) and process.versions.node (Node).",
192
+ });
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Rule: `no-next-url`.
198
+ *
199
+ * `request.nextUrl` is Next.js's extended URL. WebJs hands middleware and
200
+ * route handlers the platform Request, so the property throws in dev with the
201
+ * fix (and answers a plain URL in production rather than crashing a live app).
202
+ * Matched as `req.nextUrl` / `request.nextUrl` anywhere, and as any
203
+ * `.nextUrl` inside a middleware or proxy file, so an app's own `nextUrl`
204
+ * (a pagination cursor on a response object) is left alone.
205
+ *
206
+ * @param {ScannedFile[]} files every JS/TS file in the app, raw and redacted
207
+ * @param {Violation[]} violations accumulator the rule pushes onto
208
+ * @returns {void}
209
+ */
210
+ export function checkNoNextUrl(files, violations) {
211
+ for (const { rel, content } of files) {
212
+ if (!content.includes('nextUrl')) continue;
213
+ // Fully blanked (comment, string and template bodies), so a mention in
214
+ // prose or a docs sample is not code.
215
+ const code = redactStringsAndTemplates(content, true);
216
+ const isMiddleware = /(^|\/)(middleware|proxy)\.(?:m?[jt]s)$/.test(rel);
217
+ const hit = isMiddleware ? /\.nextUrl\b/.test(code) : /\b(?:req|request)\.nextUrl\b/.test(code);
218
+ if (!hit) continue;
219
+ violations.push({
220
+ rule: 'no-next-url',
221
+ file: rel,
222
+ message: 'Reads request.nextUrl, Next.js\'s extended URL. WebJs passes the platform Request, so it throws in dev.',
223
+ fix: 'Use new URL(request.url) (its pathname and searchParams are what nextUrl offered).',
224
+ });
225
+ }
226
+ }
227
+
180
228
  /**
181
229
  * Rule: `shell-in-non-root-layout`.
182
230
  *
@@ -12,6 +12,7 @@ import { walk } from '../fs-walk.js';
12
12
  import {
13
13
  redactStringsAndTemplates,
14
14
  } from '../js-scan.js';
15
+ import { findDecorator, decoratorReport } from '../lit-hints.js';
15
16
 
16
17
  /**
17
18
  * @typedef {import('./rules.js').Violation} Violation
@@ -134,8 +135,11 @@ export async function checkNoNonErasableTypescript(appDir, violations) {
134
135
  // Walk every .ts / .mts file under appDir, skipping node_modules,
135
136
  // build outputs, version control, and the framework's own private
136
137
  // folders. Match the conventional excludes that fs-walk.js's caller
137
- // contract expects.
138
- for await (const abs of walk(appDir, (p) => /\.m?ts$/.test(p))) {
138
+ // contract expects. Plain .js / .mjs files are walked too, for the
139
+ // decorator scan alone: no JavaScript runtime WebJs serves to runs
140
+ // decorators either, so `@customElement` in a .js component is the same
141
+ // load-time SyntaxError.
142
+ for await (const abs of walk(appDir, (p) => /\.m?[jt]s$/.test(p))) {
139
143
  // Skip anything inside node_modules or common build / cache dirs.
140
144
  const relPath = relative(appDir, abs);
141
145
  if (
@@ -148,6 +152,10 @@ export async function checkNoNonErasableTypescript(appDir, violations) {
148
152
  ) {
149
153
  continue;
150
154
  }
155
+ const isTs = /\.m?ts$/.test(abs);
156
+ // A .js file under public/ is a static asset (a service worker, a
157
+ // vendored script), never an app module the server loads.
158
+ if (!isTs && relPath.startsWith('public' + sep)) continue;
151
159
  let content;
152
160
  try {
153
161
  content = await readFile(abs, 'utf8');
@@ -159,6 +167,20 @@ export async function checkNoNonErasableTypescript(appDir, violations) {
159
167
  // inside `html\`...\`` template literals don't trip the rule.
160
168
  // The redactor preserves line + column so the reported line
161
169
  // number still maps to the right place in the original.
170
+ // Decorators: Lit's `@customElement` / `@property` / `@state` /
171
+ // `@query` habit. Node's stripper passes them through untouched, so the
172
+ // module dies at load with a bare SyntaxError that names neither the
173
+ // decorator nor the fix. Scanned on the fully blanked mask (string
174
+ // bodies too), so `@click=${...}` in an html template, `@media` in a
175
+ // css template, and `@param` in a JSDoc block never match.
176
+ if (content.includes('@')) {
177
+ const hit = findDecorator(content);
178
+ if (hit) {
179
+ const { message, fix } = decoratorReport(hit);
180
+ violations.push({ rule: 'no-non-erasable-typescript', file: relPath, message, fix });
181
+ }
182
+ }
183
+ if (!isTs) continue;
162
184
  const scan = redactStringsAndTemplates(content);
163
185
  for (const { name, regex, fix } of NON_ERASABLE_PATTERNS) {
164
186
  const m = scan.match(regex);