@webjsdev/server 0.8.62 → 0.8.64
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/index.d.ts +32 -3
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +1 -1
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/rate-limit.js +50 -5
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
package/index.d.ts
CHANGED
|
@@ -336,8 +336,13 @@ export declare function setVendorEntries(
|
|
|
336
336
|
// vendor.js (the `webjs vendor` CLI surface)
|
|
337
337
|
// ---------------------------------------------------------------------------
|
|
338
338
|
|
|
339
|
-
/**
|
|
340
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Bare-specifier npm imports an app could load in the browser, found by walking
|
|
341
|
+
* the module graph from every browser-bound entry rather than by scanning the
|
|
342
|
+
* directory. Applies no elision pruning, so the result is a superset of what the
|
|
343
|
+
* running server serves.
|
|
344
|
+
*/
|
|
345
|
+
export declare function scanBareImports(dir: string): Promise<Set<string>>;
|
|
341
346
|
/** Extract the package name from an import specifier (`dayjs/plugin/utc` -> `dayjs`). */
|
|
342
347
|
export declare function extractPackageName(spec: string): string | null;
|
|
343
348
|
/** Resolve bare imports to CDN importmap entries. */
|
|
@@ -635,6 +640,21 @@ export declare function rateLimit(opts?: {
|
|
|
635
640
|
* that STRIPS inbound `X-Forwarded-For` before adding its own.
|
|
636
641
|
*/
|
|
637
642
|
trustProxy?: boolean;
|
|
643
|
+
/**
|
|
644
|
+
* Name the ONE forwarded header that carries the visitor, e.g.
|
|
645
|
+
* `'cf-connecting-ip'` behind Cloudflare. Requires `trustProxy: true`, and
|
|
646
|
+
* when set it is the only wire header consulted (falling back to the stamped
|
|
647
|
+
* peer, then `_anon_`).
|
|
648
|
+
*
|
|
649
|
+
* Behind a CDN this is usually required for the limiter to work at all. The
|
|
650
|
+
* default chain takes the leftmost `X-Forwarded-For` entry, which behind
|
|
651
|
+
* Cloudflare is Cloudflare's EGRESS address, not the visitor, and since that
|
|
652
|
+
* is pinned per connection the limiter hands out one bucket per connection.
|
|
653
|
+
* The framework will not prefer `CF-Connecting-IP` on its own: Cloudflare
|
|
654
|
+
* overwrites it, so it is unforgeable behind Cloudflare and forgeable
|
|
655
|
+
* anywhere else, which makes the right choice a property of your topology.
|
|
656
|
+
*/
|
|
657
|
+
clientIpHeader?: string;
|
|
638
658
|
}): Middleware;
|
|
639
659
|
/** Parse a window string (`'1m'`, `'30s'`) to milliseconds. */
|
|
640
660
|
export declare function parseWindow(w: number | string): number;
|
|
@@ -645,8 +665,17 @@ export declare function parseWindow(w: number | string): number;
|
|
|
645
665
|
* `trustProxy: true` reads the forwarded-IP headers instead, UNLESS
|
|
646
666
|
* `WEBJS_NO_TRUST_PROXY=1` is set, which overrides the option back to the
|
|
647
667
|
* stamped peer and logs once per process.
|
|
668
|
+
*
|
|
669
|
+
* `header` names the one forwarded header to trust (`'cf-connecting-ip'`
|
|
670
|
+
* behind Cloudflare) and, when given, is the only wire header read. Use it
|
|
671
|
+
* whenever a CDN sits in front: the default chain's leftmost
|
|
672
|
+
* `X-Forwarded-For` entry is then the CDN's egress address rather than the
|
|
673
|
+
* visitor.
|
|
648
674
|
*/
|
|
649
|
-
export declare function clientIp(
|
|
675
|
+
export declare function clientIp(
|
|
676
|
+
req: Request,
|
|
677
|
+
opts?: { trustProxy?: boolean; header?: string },
|
|
678
|
+
): string;
|
|
650
679
|
/** Stamp the socket remote address onto a request for `clientIp` to read. */
|
|
651
680
|
export declare function stampRemoteIp(req: Request, remoteAddress: string): void;
|
|
652
681
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.64",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@webjsdev/core": "^0.7.
|
|
31
|
+
"@webjsdev/core": "^0.7.51",
|
|
32
32
|
"ws": "^8.20.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser-bound ENTRY files of an app: every module the client boot can
|
|
3
|
+
* import directly.
|
|
4
|
+
*
|
|
5
|
+
* Feeding these to `reachableFromEntries` produces webjs's equivalent of
|
|
6
|
+
* Next.js's bundler-produced page manifest, derived lazily on the first
|
|
7
|
+
* request (and re-derived on every rebuild) instead of at compile time. The
|
|
8
|
+
* dev server uses that closure as an authorization gate on its source-file
|
|
9
|
+
* branch: in-set is served (subject to the `.server.{js,ts}` stub guardrail),
|
|
10
|
+
* out-of-set 404s. Feeding them to `reachableBareSpecifiers` produces the
|
|
11
|
+
* vendor importmap, which is why this half is its own module: the CLI-side
|
|
12
|
+
* vendor paths (`pinAll`, `webjs doctor`) root at exactly the entries the gate
|
|
13
|
+
* does, without booting a server.
|
|
14
|
+
*
|
|
15
|
+
* Entries:
|
|
16
|
+
* - page / layout (both re-run on the client for hydration)
|
|
17
|
+
* - the error / loading / not-found / forbidden / unauthorized boundaries
|
|
18
|
+
* - the two root-only boundaries (global-error, global-not-found)
|
|
19
|
+
* - instrumentation-client (imported first in the boot)
|
|
20
|
+
* - every discovered component (a `static lazy` one is fetched by the lazy
|
|
21
|
+
* loader rather than imported by a page, so the component scan is an entry
|
|
22
|
+
* source in its own right)
|
|
23
|
+
*
|
|
24
|
+
* NOT entries, because the browser never fetches them as a module:
|
|
25
|
+
* - route.{js,ts} (API handlers) and middleware.{js,ts}
|
|
26
|
+
* - metadata routes (sitemap.js, robots.js, manifest.js, …)
|
|
27
|
+
* - .server.{js,ts} files (the browser gets a stub, not the source)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Components are passed in (rather than rescanned) so the caller can share one
|
|
32
|
+
* scan with `primeComponentRegistry`, saving a full appDir walk per analysis.
|
|
33
|
+
*
|
|
34
|
+
* @param {Awaited<ReturnType<typeof import('./router.js').buildRouteTable>>} routeTable
|
|
35
|
+
* @param {Awaited<ReturnType<typeof import('./component-scanner.js').scanComponents>>} components
|
|
36
|
+
* @returns {Set<string>}
|
|
37
|
+
*/
|
|
38
|
+
export function browserEntryFiles(routeTable, components) {
|
|
39
|
+
/** @type {Set<string>} */
|
|
40
|
+
const entries = new Set();
|
|
41
|
+
for (const page of routeTable.pages) {
|
|
42
|
+
if (page.file) entries.add(page.file);
|
|
43
|
+
for (const f of page.layouts || []) entries.add(f);
|
|
44
|
+
for (const f of page.errors || []) entries.add(f);
|
|
45
|
+
for (const f of page.loadings || []) entries.add(f);
|
|
46
|
+
for (const f of page.forbiddens || []) entries.add(f);
|
|
47
|
+
for (const f of page.unauthorizeds || []) entries.add(f);
|
|
48
|
+
}
|
|
49
|
+
if (routeTable.notFound) entries.add(routeTable.notFound);
|
|
50
|
+
if (routeTable.notFounds) {
|
|
51
|
+
for (const f of routeTable.notFounds.values()) entries.add(f);
|
|
52
|
+
}
|
|
53
|
+
if (routeTable.globalError) entries.add(routeTable.globalError);
|
|
54
|
+
if (routeTable.globalNotFound) entries.add(routeTable.globalNotFound);
|
|
55
|
+
// instrumentation-client is browser-bound (imported first in the boot), so it
|
|
56
|
+
// must be servable through the gate.
|
|
57
|
+
if (routeTable.instrumentationClient) entries.add(routeTable.instrumentationClient);
|
|
58
|
+
// Lazy components live in the registry but no page imports their
|
|
59
|
+
// class directly; the lazy-loader fetches their module URLs on
|
|
60
|
+
// viewport entry. Add every discovered component file as an entry so
|
|
61
|
+
// the graph walk covers both eager and lazy paths.
|
|
62
|
+
for (const c of components) entries.add(c.file);
|
|
63
|
+
return entries;
|
|
64
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { sep } from 'node:path';
|
|
2
|
+
import { matchClosingBrace } from '../js-scan.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check whether a file has the `'use server'` directive in its first
|
|
6
|
+
* five lines. Used by the `use-server-needs-extension` rule, and by
|
|
7
|
+
* `isServerActionFile` below.
|
|
8
|
+
* @param {string} content file content (already read)
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function hasUseServerDirective(content) {
|
|
12
|
+
const head = content.split('\n').slice(0, 5).join('\n');
|
|
13
|
+
return /^\s*(['"])use server\1\s*;?\s*$/m.test(head);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check whether a file is a server action. A server action requires
|
|
18
|
+
* BOTH the `.server.{js,ts,mts,mjs}` extension AND the `'use server'`
|
|
19
|
+
* directive in the file head. Either alone is not enough: bare `.server.ts`
|
|
20
|
+
* is a server-only utility (no RPC), and bare `'use server'` is a lint
|
|
21
|
+
* violation (use-server-needs-extension).
|
|
22
|
+
* @param {string} filePath absolute path
|
|
23
|
+
* @param {string} content file content (already read)
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function isServerActionFile(filePath, content) {
|
|
27
|
+
if (!/\.server\.m?[jt]s$/.test(filePath)) return false;
|
|
28
|
+
return hasUseServerDirective(content);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check whether a file resides under a components/ directory (shared or
|
|
33
|
+
* module-scoped).
|
|
34
|
+
* @param {string} relPath Path relative to appDir
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function isComponentFile(relPath) {
|
|
38
|
+
const segments = relPath.split(sep);
|
|
39
|
+
return segments.includes('components');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Scan a class body for class-field initializers naming any of `props`.
|
|
44
|
+
* "Class-field" means: at the top of the class body (brace depth 0
|
|
45
|
+
* relative to the body), at the start of a line, NOT prefixed with
|
|
46
|
+
* `declare`, `static`, or `this.`.
|
|
47
|
+
*
|
|
48
|
+
* Returns the offending property names. The caller maps these to
|
|
49
|
+
* Violation objects.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} classBody
|
|
52
|
+
* @param {Set<string>} props
|
|
53
|
+
* @returns {string[]}
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* True when a factory prop value is an array-typed `prop<…>(…)` whose
|
|
57
|
+
* runtime constructor argument is `Object`. The generic and the
|
|
58
|
+
* constructor sit in the same call, so it is decidable from the value
|
|
59
|
+
* text alone. The match is greedy on the generic so a nested generic
|
|
60
|
+
* (`prop<Array<X>>(Object)`) closes at the outer `>` that precedes the
|
|
61
|
+
* `(Object` call. A bare constructor (`Object`, with no generic) or a
|
|
62
|
+
* non-array generic (`prop<Foo>(Object)`) returns false.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} value the raw prop value text, e.g. `prop<Tag[]>(Object)`
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function arrayPropUsesObject(value) {
|
|
68
|
+
const m = /^prop\s*<([\s\S]*)>\s*\(\s*Object\s*[,)]/.exec(value.trim());
|
|
69
|
+
if (!m) return false;
|
|
70
|
+
return isArrayTypeText(m[1]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when a TypeScript type expression denotes an array: `T[]`,
|
|
75
|
+
* `readonly T[]`, `T[][]`, `Array<T>`, or `ReadonlyArray<T>`.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} type
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function isArrayTypeText(type) {
|
|
81
|
+
const bare = type.trim().replace(/^readonly\s+/, '');
|
|
82
|
+
if (/\[\s*\]$/.test(bare)) return true;
|
|
83
|
+
if (/^(?:Readonly)?Array\s*<[\s\S]*>$/.test(bare)) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function findFieldInitializers(classBody, props) {
|
|
88
|
+
/** @type {string[]} */
|
|
89
|
+
const out = [];
|
|
90
|
+
const n = classBody.length;
|
|
91
|
+
// Match class-field declarations. Two shapes:
|
|
92
|
+
// 1. With initializer: `count = 0`, `count: number = 0`, `public count = 0`
|
|
93
|
+
// 2. Type-only (no initializer): `count!: number`, `count?: number`, `count: number`
|
|
94
|
+
// Both compile to Object.defineProperty after super() under modern class-field
|
|
95
|
+
// semantics, clobbering the reactive accessor.
|
|
96
|
+
// The initializer regex: optional modifier, identifier, optional type, then `=`.
|
|
97
|
+
const initRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
|
|
98
|
+
// The type-only regex: optional modifier, identifier, then `!:` or `?:` or `:` with a type.
|
|
99
|
+
const typeOnlyRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*[!?]?\s*:\s*\S/;
|
|
100
|
+
const examineLine = (lineStart) => {
|
|
101
|
+
let j = lineStart;
|
|
102
|
+
while (j < n && classBody[j] !== '\n') j++;
|
|
103
|
+
const line = classBody.slice(lineStart, j);
|
|
104
|
+
const initM = initRe.exec(line);
|
|
105
|
+
const typeM = typeOnlyRe.exec(line);
|
|
106
|
+
// Prefer the initializer match; if neither, skip.
|
|
107
|
+
const name = initM ? initM[2] : (typeM ? typeM[2] : null);
|
|
108
|
+
// `declare`, `static`, and `this.` patterns shouldn't reach here
|
|
109
|
+
// (declare/static start with their keyword, this.x has the dot in
|
|
110
|
+
// the regex group), but guard against matching keywords as names:
|
|
111
|
+
if (name && name !== 'declare' && name !== 'static' && props.has(name)) out.push(name);
|
|
112
|
+
};
|
|
113
|
+
// Walk the body char by char, tracking brace depth. A class field lives at
|
|
114
|
+
// the class-body top level (depth 0). We examine a line ONCE, at its first
|
|
115
|
+
// non-whitespace char, only while depth is 0, and WITHOUT skipping the braces
|
|
116
|
+
// on that line: an opening brace on the line (`method() {`, `field = {`) must
|
|
117
|
+
// still be counted so the lines inside its block are seen at depth > 0. The
|
|
118
|
+
// earlier version jumped `i` past the whole examined line, which dropped that
|
|
119
|
+
// line's braces and let object-literal keys inside a method body (`{ game:
|
|
120
|
+
// ..., scoreboard: ... }`) be misread as depth-0 class fields (#934).
|
|
121
|
+
let depth = 0;
|
|
122
|
+
let i = 0;
|
|
123
|
+
let lineStart = 0;
|
|
124
|
+
let examined = false;
|
|
125
|
+
let str = '';
|
|
126
|
+
while (i < n) {
|
|
127
|
+
const c = classBody[i];
|
|
128
|
+
if (str) {
|
|
129
|
+
if (c === '\\') { i += 2; continue; }
|
|
130
|
+
if (c === str) str = '';
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (c === '\n') { lineStart = i + 1; examined = false; i++; continue; }
|
|
135
|
+
if (c === '/' && classBody[i + 1] === '/') {
|
|
136
|
+
while (i < n && classBody[i] !== '\n') i++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (c === '/' && classBody[i + 1] === '*') {
|
|
140
|
+
i += 2;
|
|
141
|
+
while (i < n && !(classBody[i] === '*' && classBody[i + 1] === '/')) i++;
|
|
142
|
+
i += 2;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
|
|
146
|
+
// First non-whitespace char of a class-body top-level line: examine it for
|
|
147
|
+
// a field declaration BEFORE consuming any brace it opens.
|
|
148
|
+
if (depth === 0 && !examined && !/\s/.test(c)) {
|
|
149
|
+
examined = true;
|
|
150
|
+
examineLine(lineStart);
|
|
151
|
+
}
|
|
152
|
+
if (c === '{') { depth++; i++; continue; }
|
|
153
|
+
if (c === '}') { depth--; i++; continue; }
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Browser-only globals that are undefined during SSR (the server-side
|
|
160
|
+
// WebComponent base is a bare class with no DOM). High-confidence names only
|
|
161
|
+
// (unlikely to be ordinary local variables), so the rule stays low-noise.
|
|
162
|
+
const BROWSER_GLOBALS = [
|
|
163
|
+
'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
|
|
164
|
+
'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
|
|
165
|
+
'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
// HTMLElement instance members that do not exist on the server element shim,
|
|
169
|
+
// so `this.<member>` throws (a method call) or is `undefined` (a property) at
|
|
170
|
+
// SSR. The attribute methods (get/set/has/remove/toggleAttribute), the event
|
|
171
|
+
// methods (add/removeEventListener, dispatchEvent), and attachInternals are
|
|
172
|
+
// backed by the shim and run server-side, so they are intentionally NOT
|
|
173
|
+
// flagged: a component may read attributes in render and reflect properties
|
|
174
|
+
// during the SSR update cycle. What stays is the genuinely browser-only
|
|
175
|
+
// surface (DOM querying, layout reads, shadow construction, focus).
|
|
176
|
+
const HTMLELEMENT_MEMBERS = [
|
|
177
|
+
'attachShadow', 'shadowRoot', 'classList',
|
|
178
|
+
'querySelector', 'querySelectorAll', 'getBoundingClientRect',
|
|
179
|
+
'focus', 'blur', 'scrollIntoView',
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract the body text of a named method from a (redacted) class body, or
|
|
184
|
+
* '' if absent. Handles `async`, a TS return-type annotation, and params.
|
|
185
|
+
* @param {string} classBody
|
|
186
|
+
* @param {string} name
|
|
187
|
+
*/
|
|
188
|
+
export function methodBodyOf(classBody, name) {
|
|
189
|
+
const re = new RegExp(`(?:^|[\\s;}])(?:async\\s+)?${name}\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`, 'g');
|
|
190
|
+
const m = re.exec(classBody);
|
|
191
|
+
if (!m) return '';
|
|
192
|
+
const open = classBody.indexOf('{', m.index + m[0].length - 1);
|
|
193
|
+
if (open === -1) return '';
|
|
194
|
+
const close = matchClosingBrace(classBody, open + 1);
|
|
195
|
+
return close === -1 ? '' : classBody.slice(open + 1, close);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Find browser-only globals and HTMLElement `this.<member>` accesses in a
|
|
200
|
+
* (redacted) method body. Returns one entry per distinct member.
|
|
201
|
+
* @param {string} code
|
|
202
|
+
* @returns {{ member: string, kind: string }[]}
|
|
203
|
+
*/
|
|
204
|
+
export function findBrowserMemberUses(code) {
|
|
205
|
+
// The class body arrives template-redacted, but `redactStringsAndTemplates`
|
|
206
|
+
// keeps single/double-quoted string CONTENT (real specifiers ride strings).
|
|
207
|
+
// Blank that too so a browser word inside a string literal (e.g. a label
|
|
208
|
+
// `'open the document'`) is not mistaken for a real global access.
|
|
209
|
+
code = code
|
|
210
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, (s) => `'${' '.repeat(Math.max(0, s.length - 2))}'`)
|
|
211
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, (s) => `"${' '.repeat(Math.max(0, s.length - 2))}"`);
|
|
212
|
+
const out = [];
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
const gRe = new RegExp(`(?<![.\\w$])(${BROWSER_GLOBALS.join('|')})\\b`, 'g');
|
|
215
|
+
let m;
|
|
216
|
+
while ((m = gRe.exec(code)) !== null) {
|
|
217
|
+
if (seen.has(m[1])) continue;
|
|
218
|
+
seen.add(m[1]);
|
|
219
|
+
out.push({ member: m[1], kind: 'a browser global' });
|
|
220
|
+
}
|
|
221
|
+
const hRe = new RegExp(`\\bthis\\.(${HTMLELEMENT_MEMBERS.join('|')})\\b`, 'g');
|
|
222
|
+
while ((m = hRe.exec(code)) !== null) {
|
|
223
|
+
const key = `this.${m[1]}`;
|
|
224
|
+
if (seen.has(key)) continue;
|
|
225
|
+
seen.add(key);
|
|
226
|
+
out.push({ member: key, kind: 'an HTMLElement member' });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* True if any `export const/let/var` declares more than one binding
|
|
233
|
+
* (`export const a = 1, b = 2`), which the single-name collector would
|
|
234
|
+
* under-count. Depth-aware: a comma inside an initializer (`f(a, b)`, `[a, b]`,
|
|
235
|
+
* `{ a, b }`) does not count, only a top-level comma before the statement end.
|
|
236
|
+
* Bailing the whole module on this errs toward a false negative (safe), never a
|
|
237
|
+
* false positive. Runs on the string/comment-redacted `scan`.
|
|
238
|
+
* @param {string} scan
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
export function hasMultiDeclaratorExport(scan) {
|
|
242
|
+
const re = /\bexport\s+(?:const|let|var)\b/g;
|
|
243
|
+
let m;
|
|
244
|
+
while ((m = re.exec(scan))) {
|
|
245
|
+
let depth = 0; // () [] {} nesting
|
|
246
|
+
let angle = 0; // <> generics, tracked ONLY inside a type annotation
|
|
247
|
+
let seenEq = false; // passed this declarator's `=` (now in the initializer)
|
|
248
|
+
let inType = false; // inside a `: Type` annotation (before the `=`)
|
|
249
|
+
for (let i = m.index + m[0].length; i < scan.length; i++) {
|
|
250
|
+
const ch = scan[i];
|
|
251
|
+
if (ch === '(' || ch === '[' || ch === '{') { depth++; continue; }
|
|
252
|
+
if (ch === ')' || ch === ']' || ch === '}') { if (depth === 0) break; depth--; continue; }
|
|
253
|
+
if (depth !== 0) continue;
|
|
254
|
+
if (ch === ';') break;
|
|
255
|
+
// A plain `=` ends the type annotation and enters the initializer. `<=`,
|
|
256
|
+
// `>=`, `=>`, `==` are harmless here (seenEq only latches true).
|
|
257
|
+
if (ch === '=') { seenEq = true; inType = false; angle = 0; continue; }
|
|
258
|
+
// A `:` BEFORE the `=` opens the type annotation. A `:` after `=` (a
|
|
259
|
+
// ternary in the initializer) is NOT a type and must not suppress a real
|
|
260
|
+
// declarator comma.
|
|
261
|
+
if (ch === ':' && !seenEq) { inType = true; continue; }
|
|
262
|
+
// Inside a type annotation `<` / `>` are generic delimiters (a comparison
|
|
263
|
+
// only appears in the initializer, after `=`). Track their depth so a
|
|
264
|
+
// generic's comma (`Map<string, number>`) is skipped while a real
|
|
265
|
+
// declarator comma (`a: number, b: number`) still counts. A `>` that is
|
|
266
|
+
// part of a `=>` function-type arrow is not a generic close.
|
|
267
|
+
if (inType && ch === '<') { angle++; continue; }
|
|
268
|
+
if (inType && ch === '>' && scan[i - 1] !== '=') { if (angle > 0) angle--; continue; }
|
|
269
|
+
// A top-level comma starts a second declarator UNLESS it is a generic's
|
|
270
|
+
// comma inside a type annotation.
|
|
271
|
+
if (ch === ',' && !(inType && angle > 0)) return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Enumerate a module's provable named exports from its redacted `scan`, for the
|
|
279
|
+
* `no-missing-local-import` rule. Returns `null` when the export list is NOT
|
|
280
|
+
* fully enumerable (a `export *` star re-export, a destructuring export, or a
|
|
281
|
+
* multi-declarator `export const a = .., b = ..`), which tells the rule to treat
|
|
282
|
+
* the module as unknowable and flag nothing from it. Otherwise returns the set
|
|
283
|
+
* of exported names (the alias after `as` for a clause, and type/interface/enum
|
|
284
|
+
* names too, so a value import of a type is not falsely flagged; tsc owns that).
|
|
285
|
+
* @param {string} scan string/template/comment-redacted source
|
|
286
|
+
* @returns {Set<string>|null}
|
|
287
|
+
*/
|
|
288
|
+
export function enumerableExports(scan) {
|
|
289
|
+
// Unknowable export shapes: bail so the rule never false-positives.
|
|
290
|
+
if (/\bexport\s*\*/.test(scan)) return null; // export * from
|
|
291
|
+
if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) return null; // destructuring export
|
|
292
|
+
if (hasMultiDeclaratorExport(scan)) return null; // export const a=1, b=2
|
|
293
|
+
const names = new Set();
|
|
294
|
+
let m;
|
|
295
|
+
const collect = (re) => { while ((m = re.exec(scan))) names.add(m[1]); };
|
|
296
|
+
collect(/\bexport\s+(?:async\s+)?function\b\s*\*?\s*([A-Za-z_$][\w$]*)/g);
|
|
297
|
+
collect(/\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g);
|
|
298
|
+
collect(/\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g);
|
|
299
|
+
collect(/\bexport\s+(?:type|interface|enum|namespace)\s+([A-Za-z_$][\w$]*)/g);
|
|
300
|
+
// A named-default import (`import { default as Foo }`) is legal against an
|
|
301
|
+
// `export default` module, so record the `default` name when present.
|
|
302
|
+
if (/\bexport\s+default\b/.test(scan)) names.add('default');
|
|
303
|
+
// `export { a, b as c }` / `export type { ... }` / `export { x } from '...'`:
|
|
304
|
+
// the EXPORTED name is the alias after `as`, else the bare name.
|
|
305
|
+
const reClause = /\bexport\s+(?:type\s+)?\{([^}]*)\}/g;
|
|
306
|
+
while ((m = reClause.exec(scan))) {
|
|
307
|
+
for (let part of m[1].split(',')) {
|
|
308
|
+
part = part.trim().replace(/^type\s+/, '');
|
|
309
|
+
if (!part) continue;
|
|
310
|
+
const name = part.split(/\s+as\s+/).pop().trim();
|
|
311
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return names;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The NAMED VALUE names a single `import { ... } from` clause pulls in, for the
|
|
319
|
+
* `no-missing-local-import` rule. Returns `null` for an `import type { ... }`
|
|
320
|
+
* (all type, skip), and `[]` for a default / namespace / side-effect import
|
|
321
|
+
* (nothing named to verify). A per-specifier inline `type` marker is dropped,
|
|
322
|
+
* and for `a as b` the IMPORTED name is `a` (what the target must export).
|
|
323
|
+
* @param {string} clause the text between `import` and `from`
|
|
324
|
+
* @returns {string[]|null}
|
|
325
|
+
*/
|
|
326
|
+
/**
|
|
327
|
+
* The `{ local, imported }` pairs a named import clause binds, for a rule that
|
|
328
|
+
* has to match the LOCAL name against code (`action=${reader}`) while reporting
|
|
329
|
+
* the IMPORTED one (`readIt`, which is what the target module calls it).
|
|
330
|
+
*
|
|
331
|
+
* `importedValueNames` cannot serve here: it returns only the imported side,
|
|
332
|
+
* which is the wrong name to match against a template hole under `as`.
|
|
333
|
+
*
|
|
334
|
+
* @param {string} clause the text between `import` and `from`
|
|
335
|
+
* @returns {{ local: string, imported: string }[]}
|
|
336
|
+
*/
|
|
337
|
+
export function importedLocalNames(clause) {
|
|
338
|
+
if (/^\s*type\b/.test(clause)) return [];
|
|
339
|
+
const brace = clause.match(/\{([^}]*)\}/);
|
|
340
|
+
if (!brace) return [];
|
|
341
|
+
/** @type {{ local: string, imported: string }[]} */
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const part of brace[1].split(',')) {
|
|
344
|
+
const seg = part.trim().replace(/^type\s+/, '');
|
|
345
|
+
if (!seg) continue;
|
|
346
|
+
const as = seg.split(/\s+as\s+/);
|
|
347
|
+
const imported = as[0].trim();
|
|
348
|
+
const local = (as[1] || as[0]).trim();
|
|
349
|
+
if (/^[A-Za-z_$][\w$]*$/.test(local) && /^[A-Za-z_$][\w$]*$/.test(imported)) {
|
|
350
|
+
out.push({ local, imported });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function importedValueNames(clause) {
|
|
357
|
+
if (/^\s*type\b/.test(clause)) return null; // import type { ... }
|
|
358
|
+
const brace = clause.match(/\{([^}]*)\}/);
|
|
359
|
+
if (!brace) return []; // default / namespace only
|
|
360
|
+
return brace[1].split(',')
|
|
361
|
+
.map((s) => s.trim())
|
|
362
|
+
.filter((s) => s && !/^type\s/.test(s))
|
|
363
|
+
.map((s) => s.split(/\s+as\s+/)[0].trim())
|
|
364
|
+
.filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
|
|
365
|
+
}
|