@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.
- package/README.md +4 -1
- package/index.d.ts +427 -109
- 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 +96 -14
- 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 +262 -0
- package/src/core-exports.js +190 -0
- 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 +140 -65
- 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 +31 -2
- package/src/testing.js +80 -17
- 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
package/src/jwe.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session token, encrypted as Auth.js v5 encrypts it (#1686): a compact
|
|
3
|
+
* JWE with `alg: "dir"` and `enc: "A256CBC-HS512"`, keyed by HKDF-SHA256 over
|
|
4
|
+
* the secret with the cookie name as salt and
|
|
5
|
+
* `Auth.js Generated Encryption Key (<salt>)` as info (64 bytes), and a `kid`
|
|
6
|
+
* that is the key's JWK thumbprint, so several secrets can rotate. Its
|
|
7
|
+
* contents are unreadable to the browser, where a signed JWT is only base64.
|
|
8
|
+
*
|
|
9
|
+
* Built on Web Crypto alone (A256CBC-HS512 is AES-256-CBC plus a truncated
|
|
10
|
+
* HMAC-SHA-512 over the AAD, IV, ciphertext and AAD length, RFC 7518 5.2), so
|
|
11
|
+
* it runs the same on Node and Bun with no dependency. Reference:
|
|
12
|
+
* `packages/core/src/jwt.ts` in the Auth.js repository.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const enc = new TextEncoder();
|
|
16
|
+
const dec = new TextDecoder();
|
|
17
|
+
const ALG = 'dir';
|
|
18
|
+
const ENC = 'A256CBC-HS512';
|
|
19
|
+
/** Seconds of clock skew allowed on `exp`, as Auth.js's decode. */
|
|
20
|
+
const CLOCK_TOLERANCE = 15;
|
|
21
|
+
|
|
22
|
+
/** @param {ArrayBuffer | Uint8Array} buf */
|
|
23
|
+
function b64url(buf) {
|
|
24
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
25
|
+
let s = '';
|
|
26
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
27
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} s */
|
|
31
|
+
function unb64url(s) {
|
|
32
|
+
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4));
|
|
33
|
+
const out = new Uint8Array(bin.length);
|
|
34
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {...Uint8Array} parts */
|
|
39
|
+
function concat(...parts) {
|
|
40
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
41
|
+
let o = 0;
|
|
42
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Derived keys by secret and salt: the secret and cookie name never change, so HKDF runs once. */
|
|
47
|
+
const derived = new Map();
|
|
48
|
+
|
|
49
|
+
/** 64 bytes for A256CBC-HS512, as Auth.js derives them (memoized per secret and salt). */
|
|
50
|
+
function deriveKey(secret, salt) {
|
|
51
|
+
const id = `${salt}\u0000${secret}`;
|
|
52
|
+
let key = derived.get(id);
|
|
53
|
+
if (!key) {
|
|
54
|
+
key = deriveKeyUncached(secret, salt);
|
|
55
|
+
key.catch(() => derived.delete(id));
|
|
56
|
+
derived.set(id, key);
|
|
57
|
+
}
|
|
58
|
+
return key;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function deriveKeyUncached(secret, salt) {
|
|
62
|
+
const base = await crypto.subtle.importKey('raw', enc.encode(secret), 'HKDF', false, ['deriveBits']);
|
|
63
|
+
const bits = await crypto.subtle.deriveBits(
|
|
64
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: enc.encode(salt), info: enc.encode(`Auth.js Generated Encryption Key (${salt})`) },
|
|
65
|
+
base,
|
|
66
|
+
512,
|
|
67
|
+
);
|
|
68
|
+
return new Uint8Array(bits);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Thumbprints by derived key, so a token's `kid` check costs no hash after the first. */
|
|
72
|
+
const thumbprints = new WeakMap();
|
|
73
|
+
|
|
74
|
+
/** RFC 7638 thumbprint of the key as an `oct` JWK, SHA-512 as jose picks for a 64-byte key (memoized per key). */
|
|
75
|
+
async function thumbprint(key) {
|
|
76
|
+
let t = thumbprints.get(key);
|
|
77
|
+
if (!t) { t = thumbprintUncached(key); thumbprints.set(key, t); }
|
|
78
|
+
return t;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function thumbprintUncached(key) {
|
|
82
|
+
const json = `{"k":"${b64url(key)}","kty":"oct"}`;
|
|
83
|
+
return b64url(await crypto.subtle.digest('SHA-512', enc.encode(json)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The A256CBC-HS512 authentication tag (RFC 7518 5.2.2.1). */
|
|
87
|
+
async function tagFor(macKey, aad, iv, ct) {
|
|
88
|
+
const al = new Uint8Array(8);
|
|
89
|
+
new DataView(al.buffer).setBigUint64(0, BigInt(aad.length * 8));
|
|
90
|
+
const hk = await crypto.subtle.importKey('raw', macKey, { name: 'HMAC', hash: 'SHA-512' }, false, ['sign']);
|
|
91
|
+
return new Uint8Array(await crypto.subtle.sign('HMAC', hk, concat(aad, iv, ct, al))).slice(0, 32);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {Uint8Array} a @param {Uint8Array} b */
|
|
95
|
+
function equalBytes(a, b) {
|
|
96
|
+
if (a.length !== b.length) return false;
|
|
97
|
+
let d = 0;
|
|
98
|
+
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
|
|
99
|
+
return d === 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Encrypt a session payload. `iat` and `jti` are added when absent, as
|
|
104
|
+
* Auth.js's encode sets them; `exp` is the caller's.
|
|
105
|
+
* @param {Record<string, unknown>} payload
|
|
106
|
+
* @param {string | string[]} secret the first is the one that encrypts
|
|
107
|
+
* @param {string} salt the session cookie's name
|
|
108
|
+
* @returns {Promise<string>}
|
|
109
|
+
*/
|
|
110
|
+
export async function encryptSession(payload, secret, salt) {
|
|
111
|
+
const key = await deriveKey(Array.isArray(secret) ? secret[0] : secret, salt);
|
|
112
|
+
const kid = await thumbprint(key);
|
|
113
|
+
const header = b64url(enc.encode(JSON.stringify({ alg: ALG, enc: ENC, kid })));
|
|
114
|
+
const claims = { iat: Math.floor(Date.now() / 1000), jti: crypto.randomUUID(), ...payload };
|
|
115
|
+
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
116
|
+
const aes = await crypto.subtle.importKey('raw', key.slice(32), 'AES-CBC', false, ['encrypt']);
|
|
117
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, aes, enc.encode(JSON.stringify(claims))));
|
|
118
|
+
const tag = await tagFor(key.slice(0, 32), enc.encode(header), iv, ct);
|
|
119
|
+
return `${header}..${b64url(iv)}.${b64url(ct)}.${b64url(tag)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Decrypt a session token, or null when it is not one this app made, was
|
|
124
|
+
* tampered with, or has expired. Each secret is tried, the one whose
|
|
125
|
+
* thumbprint matches the token's `kid` first, as Auth.js's decode does.
|
|
126
|
+
* @param {string} token
|
|
127
|
+
* @param {string | string[]} secret
|
|
128
|
+
* @param {string} salt the session cookie's name
|
|
129
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
130
|
+
*/
|
|
131
|
+
export async function decryptSession(token, secret, salt) {
|
|
132
|
+
try {
|
|
133
|
+
const parts = String(token).split('.');
|
|
134
|
+
if (parts.length !== 5 || parts[1] !== '') return null;
|
|
135
|
+
const [h, , ivB, ctB, tagB] = parts;
|
|
136
|
+
const header = JSON.parse(dec.decode(unb64url(h)));
|
|
137
|
+
if (header.alg !== ALG || header.enc !== ENC) return null;
|
|
138
|
+
const iv = unb64url(ivB);
|
|
139
|
+
const ct = unb64url(ctB);
|
|
140
|
+
const tag = unb64url(tagB);
|
|
141
|
+
const secrets = Array.isArray(secret) ? secret : [secret];
|
|
142
|
+
for (const s of secrets) {
|
|
143
|
+
const key = await deriveKey(s, salt);
|
|
144
|
+
if (header.kid && header.kid !== (await thumbprint(key))) continue;
|
|
145
|
+
if (!equalBytes(tag, await tagFor(key.slice(0, 32), enc.encode(h), iv, ct))) continue;
|
|
146
|
+
const aes = await crypto.subtle.importKey('raw', key.slice(32), 'AES-CBC', false, ['decrypt']);
|
|
147
|
+
const claims = JSON.parse(dec.decode(await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, aes, ct)));
|
|
148
|
+
if (typeof claims.exp === 'number' && Date.now() / 1000 > claims.exp + CLOCK_TOLERANCE) return null;
|
|
149
|
+
return claims;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `static lazy = true` components (#1524): a side-effect import of a lazy
|
|
3
|
+
* component is served as a lazy-loader registration instead of an eager
|
|
4
|
+
* import, so the browser fetches the module on first sight of its tag.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import { redactStringsAndTemplates, maskComments } from './js-scan.js';
|
|
9
|
+
|
|
10
|
+
/** A `.server.{js,ts,mjs,mts}` file: a stub on the client, inert there. */
|
|
11
|
+
const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
|
|
12
|
+
|
|
13
|
+
/** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
|
|
14
|
+
const SIDE_EFFECT_IMPORT_RE = /^([ \t]*)import\s+(['"])([^'"]+)\2\s*;?[ \t]*$/gm;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Defer side-effect imports of `static lazy` components (#1524). A lazy
|
|
18
|
+
* component that a shipping module imports for registration used to load
|
|
19
|
+
* eagerly as part of that module's static graph, which defeated `static lazy`
|
|
20
|
+
* whenever the class had to be imported at all (and SSR needs the import: the
|
|
21
|
+
* server only knows the tag is lazy once the class registered). The SERVER
|
|
22
|
+
* keeps the real import; this rewrites only the BROWSER copy, replacing the
|
|
23
|
+
* import line in place (same line, so positions stay stable) with a
|
|
24
|
+
* registration on the lazy loader:
|
|
25
|
+
*
|
|
26
|
+
* import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy({"x-tag":"/url"}));
|
|
27
|
+
*
|
|
28
|
+
* The loader then imports the module the first time an element with one of
|
|
29
|
+
* its tags is in (or near) the viewport: a hidden tab panel when it first
|
|
30
|
+
* shows, a closed dialog's content when it opens, a below-the-fold element
|
|
31
|
+
* when it scrolls near. Its MutationObserver also catches a tag rendered
|
|
32
|
+
* later on the client, so an imported-but-not-yet-rendered component still
|
|
33
|
+
* registers when it appears.
|
|
34
|
+
*
|
|
35
|
+
* Only a side-effect import is rewritten (a binding import needs its value
|
|
36
|
+
* now, so it stays eager), the match runs over a redacted copy so an
|
|
37
|
+
* import-looking line inside a string, template, or comment is never touched,
|
|
38
|
+
* and an importer with no lazy dependency in the graph returns untouched with
|
|
39
|
+
* no regex work.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} source module source (already type-stripped if TS)
|
|
42
|
+
* @param {string} importerAbs absolute path of the importing module
|
|
43
|
+
* @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
|
|
44
|
+
* @param {Map<string, string[]> | undefined} lazyFiles lazy component file -> the tags it registers
|
|
45
|
+
* @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
|
|
46
|
+
* @param {string} appDir
|
|
47
|
+
* @param {(abs: string) => string} urlFor the browser URL the loader imports (base path + content hash applied)
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function deferLazyImportsFromSource(source, importerAbs, moduleGraph, lazyFiles, resolveImport, appDir, urlFor) {
|
|
51
|
+
if (!lazyFiles || lazyFiles.size === 0) return source;
|
|
52
|
+
const deps = moduleGraph && moduleGraph.get(importerAbs);
|
|
53
|
+
if (!deps) return source;
|
|
54
|
+
let hasLazyDep = false;
|
|
55
|
+
for (const d of deps) {
|
|
56
|
+
if (lazyFiles.has(d)) { hasLazyDep = true; break; }
|
|
57
|
+
}
|
|
58
|
+
if (!hasLazyDep) return source;
|
|
59
|
+
|
|
60
|
+
const redacted = redactStringsAndTemplates(source);
|
|
61
|
+
let out = '';
|
|
62
|
+
let last = 0;
|
|
63
|
+
for (const m of redacted.matchAll(SIDE_EFFECT_IMPORT_RE)) {
|
|
64
|
+
const start = /** @type {number} */ (m.index);
|
|
65
|
+
const end = start + m[0].length;
|
|
66
|
+
const resolved = resolveImport(m[3], importerAbs, appDir);
|
|
67
|
+
const tags = resolved ? lazyFiles.get(resolved) : undefined;
|
|
68
|
+
out += source.slice(last, start);
|
|
69
|
+
if (tags && tags.length) {
|
|
70
|
+
const url = urlFor(/** @type {string} */ (resolved));
|
|
71
|
+
const entries = Object.fromEntries(tags.map((t) => [t, url]));
|
|
72
|
+
out += `${m[1]}import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy(${JSON.stringify(entries)}));`;
|
|
73
|
+
} else {
|
|
74
|
+
out += source.slice(start, end);
|
|
75
|
+
}
|
|
76
|
+
last = end;
|
|
77
|
+
}
|
|
78
|
+
out += source.slice(last);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The lazy component files of an app (#1524): every scanned component file
|
|
84
|
+
* that declares `static lazy = true`, mapped to the tags it registers.
|
|
85
|
+
*
|
|
86
|
+
* @param {Array<{ tag: string, file: string, lazy?: boolean }>} components
|
|
87
|
+
* @returns {Map<string, string[]>}
|
|
88
|
+
*/
|
|
89
|
+
export function lazyComponentFiles(components) {
|
|
90
|
+
/** @type {Map<string, string[]>} */
|
|
91
|
+
const out = new Map();
|
|
92
|
+
for (const c of components) {
|
|
93
|
+
if (!c.lazy) continue;
|
|
94
|
+
const tags = out.get(c.file);
|
|
95
|
+
if (tags) { if (!tags.includes(c.tag)) tags.push(c.tag); } else out.set(c.file, [c.tag]);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A test module (the same rule the component scanner skips). */
|
|
101
|
+
const TEST_FILE_RE = /\.(test|spec)\.m?[jt]sx?$/;
|
|
102
|
+
|
|
103
|
+
/** `import <bindings> from 'x'` (the bindings part is captured). */
|
|
104
|
+
const BINDING_IMPORT_RE = /\bimport\s+(type\s+)?([\w$*{}\s,]+?)\s+from\s+(['"])([^'"]+)\3/g;
|
|
105
|
+
/** `export { a } from 'x'` / `export * from 'x'` (a re-export needs the module too). */
|
|
106
|
+
const REEXPORT_RE = /\bexport\s+(type\s+)?(?:\*|\{[^}]*\}|\*\s+as\s+[\w$]+)\s+from\s+(['"])([^'"]+)\2/g;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Is this import clause types only (`import type { A }`, or every named
|
|
110
|
+
* specifier marked `type`)? Those are erased before the browser sees them.
|
|
111
|
+
*
|
|
112
|
+
* @param {string | undefined} typeKw
|
|
113
|
+
* @param {string} clause
|
|
114
|
+
*/
|
|
115
|
+
function typeOnlyClause(typeKw, clause) {
|
|
116
|
+
if (typeKw) return true;
|
|
117
|
+
const m = clause.trim().match(/^\{([\s\S]*)\}$/);
|
|
118
|
+
if (!m) return false;
|
|
119
|
+
const names = m[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
120
|
+
return names.length > 0 && names.every((n) => /^type\s/.test(n));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The lazy component files some module imports WITH bindings (#1524). A
|
|
125
|
+
* binding import loads the module eagerly whatever the class says, because the
|
|
126
|
+
* importer needs the value when it runs. Such a file is not lazy in practice,
|
|
127
|
+
* so the caller drops it from the lazy set: otherwise the preload walks would
|
|
128
|
+
* skip a module the browser still fetches up front, which only makes it slower.
|
|
129
|
+
*
|
|
130
|
+
* Only importers whose graph edges reach a lazy file are read. A server file is
|
|
131
|
+
* never served, and a test file (`*.test.*` / `*.spec.*`) never ships to a
|
|
132
|
+
* page, so their imports do not count: a component's own browser test imports
|
|
133
|
+
* it with bindings, and that must not make it eager in the app.
|
|
134
|
+
*
|
|
135
|
+
* @param {Map<string, string[]>} lazyFiles
|
|
136
|
+
* @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
|
|
137
|
+
* @param {(file: string) => Promise<string>} readFileFn
|
|
138
|
+
* @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
|
|
139
|
+
* @param {string} appDir
|
|
140
|
+
* @returns {Promise<Set<string>>}
|
|
141
|
+
*/
|
|
142
|
+
export async function bindingImportedLazyFiles(lazyFiles, moduleGraph, readFileFn, resolveImport, appDir) {
|
|
143
|
+
/** @type {Set<string>} */
|
|
144
|
+
const out = new Set();
|
|
145
|
+
if (!lazyFiles.size || !moduleGraph) return out;
|
|
146
|
+
for (const [importer, deps] of moduleGraph) {
|
|
147
|
+
if (SERVER_FILE_RE.test(importer) || TEST_FILE_RE.test(importer)) continue;
|
|
148
|
+
let reaches = false;
|
|
149
|
+
for (const d of deps) if (lazyFiles.has(d) && !out.has(d)) { reaches = true; break; }
|
|
150
|
+
if (!reaches) continue;
|
|
151
|
+
let src;
|
|
152
|
+
try { src = await readFileFn(importer); } catch { continue; }
|
|
153
|
+
const code = redactStringsAndTemplates(maskComments(src));
|
|
154
|
+
for (const m of code.matchAll(BINDING_IMPORT_RE)) {
|
|
155
|
+
if (typeOnlyClause(m[1], m[2])) continue;
|
|
156
|
+
const abs = resolveImport(m[4], importer, appDir);
|
|
157
|
+
if (abs && lazyFiles.has(abs)) out.add(abs);
|
|
158
|
+
}
|
|
159
|
+
for (const m of code.matchAll(REEXPORT_RE)) {
|
|
160
|
+
if (m[1]) continue;
|
|
161
|
+
const abs = resolveImport(m[3], importer, appDir);
|
|
162
|
+
if (abs && lazyFiles.has(abs)) out.add(abs);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
package/src/listener-core.js
CHANGED
|
@@ -445,9 +445,11 @@ export class SseHub {
|
|
|
445
445
|
* and an absent or malformed verdict is emitted as `reload` here too, so the
|
|
446
446
|
* fail-safe holds on both ends of the wire and a tab running against a
|
|
447
447
|
* restarted server can never be skewed into a lighter response than the
|
|
448
|
-
* change deserves.
|
|
448
|
+
* change deserves. A verdict's `modules` and `css` (#1689) ride the same
|
|
449
|
+
* payload, and `inPlace` is read here at send time, so a handler that only
|
|
450
|
+
* learns after construction that it reloads in place still says so.
|
|
449
451
|
*
|
|
450
|
-
* @param {{ v?: string, by?: string, why?: string } | null} [verdict]
|
|
452
|
+
* @param {{ v?: string, by?: string, why?: string, modules?: string[], css?: true } | null} [verdict]
|
|
451
453
|
*/
|
|
452
454
|
reload(verdict) {
|
|
453
455
|
const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' };
|
package/src/lit-hints.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Lit-habit hint table: what to write in WebJs when Lit muscle memory
|
|
3
|
+
* reaches for something WebJs cannot have.
|
|
4
|
+
*
|
|
5
|
+
* WebJs matches Lit's component API wherever it can with no build step. Two
|
|
6
|
+
* kinds of Lit code still cannot work, and both fail far from the line that
|
|
7
|
+
* caused them unless something names the fix:
|
|
8
|
+
*
|
|
9
|
+
* - DECORATORS (`@customElement`, `@property`, `@state`, `@query`, ...).
|
|
10
|
+
* Node's type stripper passes them through untouched, so the module then
|
|
11
|
+
* dies with a bare `SyntaxError: Invalid or unexpected token` on Node and
|
|
12
|
+
* in the browser (Bun runs legacy decorators and throws a
|
|
13
|
+
* `ReferenceError` for the undefined decorator instead).
|
|
14
|
+
* - NAMES that do not exist in `@webjsdev/core` (`LitElement`, a decorator
|
|
15
|
+
* function, a type imported as a value). ESM reports them as
|
|
16
|
+
* `does not provide an export named X`, which says nothing about the fix.
|
|
17
|
+
*
|
|
18
|
+
* One table feeds every surface that reports these, so the fix text cannot
|
|
19
|
+
* drift: the `no-non-erasable-typescript` and `core-imports-exist` check
|
|
20
|
+
* rules, the dev server's post-strip scan, and the dev error overlay.
|
|
21
|
+
*/
|
|
22
|
+
import { redactStringsAndTemplates } from './js-scan.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Lit decorator name to the WebJs spelling of the same thing.
|
|
26
|
+
* @type {Readonly<Record<string, string>>}
|
|
27
|
+
*/
|
|
28
|
+
export const DECORATOR_FIXES = Object.freeze({
|
|
29
|
+
customElement:
|
|
30
|
+
"Drop `@customElement('my-el')` and register after the class: `MyEl.register('my-el')` (or `customElements.define('my-el', MyEl)`).",
|
|
31
|
+
property:
|
|
32
|
+
"Declare the property in Lit's `static properties = { count: { type: Number } }` plus a `declare count: number;` line (or pass the shape to the factory: `class X extends WebComponent({ count: Number })`), and set its default in the constructor after `super()`.",
|
|
33
|
+
state:
|
|
34
|
+
'Declare it as internal state with `static properties = { open: { state: true } }` plus `declare open: boolean;` (or hold it in a signal), and set its default in the constructor.',
|
|
35
|
+
query:
|
|
36
|
+
"Replace `@query('#x') el` with a getter: `get el() { return this.renderRoot.querySelector('#x'); }`.",
|
|
37
|
+
queryAll:
|
|
38
|
+
"Replace `@queryAll('.x') els` with a getter: `get els() { return this.renderRoot.querySelectorAll('.x'); }`.",
|
|
39
|
+
queryAsync:
|
|
40
|
+
"Replace `@queryAsync('#x') el` with an async method: `async el() { await this.updateComplete; return this.renderRoot.querySelector('#x'); }`.",
|
|
41
|
+
queryAssignedElements:
|
|
42
|
+
"Replace it with a getter over the slot: `get items() { return this.renderRoot.querySelector('slot')?.assignedElements({ flatten: true }) ?? []; }`.",
|
|
43
|
+
queryAssignedNodes:
|
|
44
|
+
"Replace it with a getter over the slot: `get nodes() { return this.renderRoot.querySelector('slot')?.assignedNodes({ flatten: true }) ?? []; }`.",
|
|
45
|
+
eventOptions:
|
|
46
|
+
'Pass listener options with a listener object in the binding: `@scroll=${{ handleEvent: (e) => this.onScroll(e), passive: true }}`.',
|
|
47
|
+
provide:
|
|
48
|
+
'Create the provider in the constructor: `new ContextProvider(this, { context: myContext, initialValue })` from `@webjsdev/core/context`.',
|
|
49
|
+
consume:
|
|
50
|
+
'Create the consumer in the constructor: `new ContextConsumer(this, { context: myContext, subscribe: true })` from `@webjsdev/core/context`.',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** The fix line shared by every decorator not in the table. */
|
|
54
|
+
export const GENERIC_DECORATOR_FIX =
|
|
55
|
+
'WebJs has no build step, so decorators cannot run. Declare reactive properties with `static properties` (or the `WebComponent({ ... })` factory), register with `MyEl.register(tag)`, and write `@query`-style lookups as getters over `this.renderRoot`.';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The fix for one decorator name.
|
|
59
|
+
* @param {string} name the decorator identifier without the `@`
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function decoratorFix(name) {
|
|
63
|
+
return Object.hasOwn(DECORATOR_FIXES, name) ? DECORATOR_FIXES[name] : GENERIC_DECORATOR_FIX;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A decorator in CODE position: at the start of a line or right after a
|
|
68
|
+
* `{`, `;` or `}`, an `@` followed by an identifier (optionally dotted), with
|
|
69
|
+
* whatever follows being another decorator, a call, or a class member / class
|
|
70
|
+
* declaration. Run over a mask in which every comment, string and template
|
|
71
|
+
* body is blanked, so `@click=${...}` in an `html` template, `@media` in a
|
|
72
|
+
* `css` template, and `@param` in a JSDoc block are never seen.
|
|
73
|
+
*/
|
|
74
|
+
const DECORATOR_RE = /(^|[{;}])([ \t]*)@([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)*(?=[ \t]*(?:\(|\r?\n|[A-Za-z_$#@*[]))/gm;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Find the first decorator in `src`, or null.
|
|
78
|
+
* @param {string} src raw JS / TS source
|
|
79
|
+
* @param {string} [scan] an already-built `redactStringsAndTemplates(src, true)` mask
|
|
80
|
+
* @returns {{ name: string, index: number, line: number, column: number } | null}
|
|
81
|
+
*/
|
|
82
|
+
export function findDecorator(src, scan) {
|
|
83
|
+
if (typeof src !== 'string' || !src.includes('@')) return null;
|
|
84
|
+
const mask = scan ?? redactStringsAndTemplates(src, true);
|
|
85
|
+
DECORATOR_RE.lastIndex = 0;
|
|
86
|
+
const m = DECORATOR_RE.exec(mask);
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
const index = m.index + m[1].length + m[2].length;
|
|
89
|
+
const before = src.slice(0, index);
|
|
90
|
+
const line = before.split('\n').length;
|
|
91
|
+
const column = index - before.lastIndexOf('\n');
|
|
92
|
+
return { name: m[3], index, line, column };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One message for a found decorator, shared by `webjs check` and dev.
|
|
97
|
+
* @param {{ name: string, line: number }} hit
|
|
98
|
+
* @returns {{ message: string, fix: string }}
|
|
99
|
+
*/
|
|
100
|
+
export function decoratorReport(hit) {
|
|
101
|
+
return {
|
|
102
|
+
message:
|
|
103
|
+
`Decorator \`@${hit.name}\` at line ${hit.line}. WebJs serves source with no build step and decorators are not erasable TypeScript: ` +
|
|
104
|
+
'Node and the browser reject the module with a bare `SyntaxError: Invalid or unexpected token`, so the component never loads.',
|
|
105
|
+
fix: decoratorFix(hit.name),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Names Lit code imports that `@webjsdev/core` will never export, mapped to the
|
|
111
|
+
* WebJs replacement. A name NOT here that is still missing from core gets the
|
|
112
|
+
* generic "not exported" message with the list of near names; a name that core
|
|
113
|
+
* exports only as a TYPE gets the `import type` fix instead.
|
|
114
|
+
* @type {Readonly<Record<string, string>>}
|
|
115
|
+
*/
|
|
116
|
+
export const MISSING_NAME_FIXES = Object.freeze({
|
|
117
|
+
LitElement: "Extend `WebComponent` from '@webjsdev/core': `class MyEl extends WebComponent { ... }`.",
|
|
118
|
+
ReactiveElement: "Extend `WebComponent` from '@webjsdev/core'. It is the reactive element and the template renderer in one class.",
|
|
119
|
+
customElement: DECORATOR_FIXES.customElement,
|
|
120
|
+
property: DECORATOR_FIXES.property,
|
|
121
|
+
state: DECORATOR_FIXES.state,
|
|
122
|
+
query: DECORATOR_FIXES.query,
|
|
123
|
+
queryAll: DECORATOR_FIXES.queryAll,
|
|
124
|
+
queryAsync: DECORATOR_FIXES.queryAsync,
|
|
125
|
+
queryAssignedElements: DECORATOR_FIXES.queryAssignedElements,
|
|
126
|
+
queryAssignedNodes: DECORATOR_FIXES.queryAssignedNodes,
|
|
127
|
+
eventOptions: DECORATOR_FIXES.eventOptions,
|
|
128
|
+
provide: DECORATOR_FIXES.provide,
|
|
129
|
+
consume: DECORATOR_FIXES.consume,
|
|
130
|
+
supportsAdoptingStyleSheets: 'Drop it. WebJs adopts `static styles` into a shadow root itself and falls back to a `<style>` element when constructable stylesheets are missing.',
|
|
131
|
+
defaultConverter: 'Drop the import. The built-in attribute conversion for `String` / `Number` / `Boolean` / `Object` / `Array` applies whenever a property declares no `converter`.',
|
|
132
|
+
notEqual: 'Drop the import. A property with no `hasChanged` already uses `!Object.is(value, oldValue)`.',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The fix for a name that `@webjsdev/core` does not export as a value.
|
|
137
|
+
* @param {string} name
|
|
138
|
+
* @param {{ typeOnly?: boolean, specifier?: string }} [info]
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function missingCoreNameFix(name, info = {}) {
|
|
142
|
+
const spec = info.specifier || '@webjsdev/core';
|
|
143
|
+
if (info.typeOnly) {
|
|
144
|
+
return `\`${name}\` is a TYPE in ${spec}, with no runtime value. Import it with \`import type { ${name} } from '${spec}'\` (or \`import { type ${name} }\`), since the type stripper keeps a plain import and the module then fails to load.`;
|
|
145
|
+
}
|
|
146
|
+
if (Object.hasOwn(MISSING_NAME_FIXES, name)) return MISSING_NAME_FIXES[name];
|
|
147
|
+
return `Remove the import of \`${name}\` or import a name ${spec} exports. Its source is readable in node_modules/@webjsdev/core.`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse the missing name and the module out of an ESM link error, on either
|
|
152
|
+
* runtime: Node's `The requested module 'x' does not provide an export named
|
|
153
|
+
* 'Y'` and Bun's `Export named 'Y' not found in module 'x'.`
|
|
154
|
+
* @param {string} message
|
|
155
|
+
* @returns {{ name: string, module: string } | null}
|
|
156
|
+
*/
|
|
157
|
+
export function parseMissingExport(message) {
|
|
158
|
+
if (typeof message !== 'string') return null;
|
|
159
|
+
let m = /requested module '([^']+)' does not provide an export named '([^']+)'/.exec(message);
|
|
160
|
+
if (m) return { module: m[1], name: m[2] };
|
|
161
|
+
m = /Export named '([^']+)' not found in module '([^']+)'/.exec(message);
|
|
162
|
+
if (m) return { module: m[2], name: m[1] };
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* True when an ESM link error names a WebJs core module.
|
|
168
|
+
* @param {string} mod
|
|
169
|
+
*/
|
|
170
|
+
export function isCoreModule(mod) {
|
|
171
|
+
return /@webjsdev\/core|\/packages\/core\/|\/__webjs\/core\//.test(mod);
|
|
172
|
+
}
|