@webjsdev/server 0.9.0 → 0.10.1
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/README.md +4 -1
- package/index.d.ts +793 -125
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +97 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +269 -0
- package/src/core-exports.js +190 -0
- package/src/csrf.js +21 -3
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +142 -66
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +71 -2
- package/src/testing.js +103 -21
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
23
|
-
* @param {Violation[]} violations
|
|
24
|
-
* @returns {void}
|
|
54
|
+
* @param {string} appDir
|
|
55
|
+
* @param {import('./rules.js').Violation[]} violations
|
|
25
56
|
*/
|
|
26
|
-
export function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
152
|
-
// WEBJS_PUBLIC_* var and not NODE_ENV
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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);
|