@webjsdev/server 0.7.3 → 0.8.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 +7 -2
- package/index.js +21 -3
- package/package.json +1 -3
- package/src/actions.js +25 -9
- package/src/cache.js +19 -2
- package/src/check.js +227 -96
- package/src/component-elision.js +851 -0
- package/src/component-scanner.js +44 -7
- package/src/context.js +36 -0
- package/src/crypto-utils.js +65 -0
- package/src/dev.js +660 -134
- package/src/importmap.js +283 -20
- package/src/js-scan.js +288 -0
- package/src/module-graph.js +194 -20
- package/src/rate-limit.js +100 -12
- package/src/script-tag-json.js +63 -0
- package/src/session.js +60 -14
- package/src/ssr.js +133 -17
- package/src/vendor.js +1261 -103
- package/src/websocket.js +3 -1
package/src/component-scanner.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Server-side scanner that walks the app tree and records the
|
|
3
3
|
* browser-visible URL for every webjs component module.
|
|
4
4
|
*
|
|
5
|
-
* Called once
|
|
5
|
+
* Called once on the first request (lazily, via `ensureReady`), then memoized. Results are used to prime the core
|
|
6
6
|
* registry (`primeModuleUrl`) BEFORE any SSR render: so when a page
|
|
7
7
|
* renders a component tag, `lookupModuleUrl(tag)` already has the URL
|
|
8
8
|
* ready for `<link rel="modulepreload">` hints.
|
|
@@ -18,11 +18,24 @@
|
|
|
18
18
|
* we only need `{ tag, className, moduleUrl }` tuples.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFile } from 'node:fs/promises';
|
|
21
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
22
22
|
import { sep } from 'node:path';
|
|
23
23
|
import { walk } from './fs-walk.js';
|
|
24
24
|
import { primeModuleUrl } from '@webjsdev/core';
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* mtime-keyed cache of extracted components per file, so a rebuild re-reads
|
|
28
|
+
* only files that changed (an unchanged file reuses its cached component list
|
|
29
|
+
* after a single `stat`). Makes the component scan incremental for large apps.
|
|
30
|
+
* Keyed by mtime AND size (a same-tick length-changing edit is caught even on
|
|
31
|
+
* coarse-mtime filesystems).
|
|
32
|
+
* @type {Map<string, { mtimeMs: number, size: number, comps: Array<{ tag: string, className: string }> }>}
|
|
33
|
+
*/
|
|
34
|
+
const SCAN_CACHE = new Map();
|
|
35
|
+
|
|
36
|
+
/** Introspection for tests/ops: is `file` currently in the scan cache? */
|
|
37
|
+
export function _scanCacheHas(file) { return SCAN_CACHE.has(file); }
|
|
38
|
+
|
|
26
39
|
/**
|
|
27
40
|
* Recognise either registration pattern:
|
|
28
41
|
*
|
|
@@ -70,21 +83,39 @@ export function extractComponents(src) {
|
|
|
70
83
|
export async function scanComponents(appDir) {
|
|
71
84
|
/** @type {Array<{ tag: string, className: string, moduleUrl: string, file: string }>} */
|
|
72
85
|
const components = [];
|
|
86
|
+
/** @type {Set<string>} live component files this scan, for cache eviction */
|
|
87
|
+
const seen = new Set();
|
|
73
88
|
const filter = (p) =>
|
|
74
89
|
/\.m?[jt]sx?$/.test(p) &&
|
|
75
90
|
!/\.(test|spec)\.m?[jt]sx?$/.test(p) &&
|
|
76
91
|
!/\.server\.m?[jt]s$/.test(p);
|
|
77
92
|
|
|
78
93
|
for await (const file of walk(appDir, filter)) {
|
|
79
|
-
let
|
|
80
|
-
try {
|
|
81
|
-
|
|
94
|
+
let mtimeMs, size;
|
|
95
|
+
try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; } catch { continue; }
|
|
96
|
+
seen.add(file); // mark live (hit and miss) for cache eviction
|
|
97
|
+
let comps;
|
|
98
|
+
const cached = SCAN_CACHE.get(file);
|
|
99
|
+
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
|
|
100
|
+
comps = cached.comps;
|
|
101
|
+
} else {
|
|
102
|
+
let src;
|
|
103
|
+
try { src = await readFile(file, 'utf8'); } catch { continue; }
|
|
104
|
+
comps = extractComponents(src);
|
|
105
|
+
SCAN_CACHE.set(file, { mtimeMs, size, comps });
|
|
106
|
+
}
|
|
82
107
|
if (!comps.length) continue;
|
|
83
108
|
const moduleUrl = toUrlPath(file, appDir);
|
|
84
109
|
for (const c of comps) {
|
|
85
110
|
components.push({ ...c, moduleUrl, file });
|
|
86
111
|
}
|
|
87
112
|
}
|
|
113
|
+
// Evict scan-cache entries for files no longer walked (renamed/deleted),
|
|
114
|
+
// scoped to this app so a multi-app process keeps other apps' entries.
|
|
115
|
+
const prefix = appDir.endsWith(sep) ? appDir : appDir + sep;
|
|
116
|
+
for (const key of SCAN_CACHE.keys()) {
|
|
117
|
+
if ((key === appDir || key.startsWith(prefix)) && !seen.has(key)) SCAN_CACHE.delete(key);
|
|
118
|
+
}
|
|
88
119
|
return components;
|
|
89
120
|
}
|
|
90
121
|
|
|
@@ -94,11 +125,17 @@ export async function scanComponents(appDir) {
|
|
|
94
125
|
* again (e.g. on dev-server rebuild after a file add), new discoveries
|
|
95
126
|
* are added and existing tags are updated.
|
|
96
127
|
*
|
|
128
|
+
* Pass `components` if you already have the scanned list (e.g. the
|
|
129
|
+
* dev server scans once and reuses for both the registry and the
|
|
130
|
+
* source-serving authorisation gate). Omitting it triggers a fresh
|
|
131
|
+
* scan, matching the original single-arg signature.
|
|
132
|
+
*
|
|
97
133
|
* @param {string} appDir
|
|
134
|
+
* @param {Awaited<ReturnType<typeof scanComponents>>} [components]
|
|
98
135
|
* @returns {Promise<{ count: number }>}
|
|
99
136
|
*/
|
|
100
|
-
export async function primeComponentRegistry(appDir) {
|
|
101
|
-
|
|
137
|
+
export async function primeComponentRegistry(appDir, components) {
|
|
138
|
+
components = components ?? await scanComponents(appDir);
|
|
102
139
|
for (const { tag, moduleUrl } of components) {
|
|
103
140
|
primeModuleUrl(tag, moduleUrl);
|
|
104
141
|
}
|
package/src/context.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { parseCookies } from './csrf.js';
|
|
3
|
+
import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Per-request context backed by AsyncLocalStorage. Lets server-side code
|
|
@@ -31,6 +32,41 @@ export function getRequest() {
|
|
|
31
32
|
return als.getStore()?.req ?? null;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Server-only implementation of the CSP nonce reader: pulls the
|
|
37
|
+
* current request from AsyncLocalStorage, parses the
|
|
38
|
+
* `script-src 'nonce-...'` value from its CSP header, returns ''
|
|
39
|
+
* when none in scope.
|
|
40
|
+
*
|
|
41
|
+
* The public `cspNonce()` function lives in `@webjsdev/core` so user
|
|
42
|
+
* layouts / pages can import it without dragging server-only deps
|
|
43
|
+
* (node:async_hooks etc.) into browser-loaded modules. The actual
|
|
44
|
+
* implementation is wired here, server-side only, via
|
|
45
|
+
* `setCspNonceProvider`. On the browser there is no provider, so
|
|
46
|
+
* `cspNonce()` returns '' (empty `nonce=""` attribute, browser
|
|
47
|
+
* ignores it).
|
|
48
|
+
*/
|
|
49
|
+
// The regex captures the first `nonce-...` token anywhere in the CSP
|
|
50
|
+
// header. Webjs uses a single per-request nonce shared across all
|
|
51
|
+
// directives that emit it (the standard CSP3 single-nonce model),
|
|
52
|
+
// so reading the first match is correct. If a future caller emits
|
|
53
|
+
// styled inline content under a separate style nonce, this reader
|
|
54
|
+
// would need to become directive-scoped. Kept identical to the
|
|
55
|
+
// matching helper in ssr.js so both paths interpret the header the
|
|
56
|
+
// same way.
|
|
57
|
+
setCspNonceProvider(() => {
|
|
58
|
+
const req = als.getStore()?.req;
|
|
59
|
+
if (!req) return '';
|
|
60
|
+
const csp = req.headers.get('content-security-policy') || '';
|
|
61
|
+
const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp);
|
|
62
|
+
return match ? match[1] : '';
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Re-export for backwards-compat: callers that imported cspNonce from
|
|
66
|
+
// @webjsdev/server still work. New code should import from
|
|
67
|
+
// @webjsdev/core for browser-isomorphism.
|
|
68
|
+
export { cspNonce };
|
|
69
|
+
|
|
34
70
|
/**
|
|
35
71
|
* Read-only headers for the in-flight request. Throws outside a request
|
|
36
72
|
* (e.g. at module top-level).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-Crypto-based hash helpers. Shared by the SSR / vendor / actions
|
|
3
|
+
* paths that previously used `node:crypto.createHash`. The Web Crypto
|
|
4
|
+
* API replaces the synchronous Node-only API with a Promise-returning
|
|
5
|
+
* one; the trade-off is portability across Node, Deno, Bun, and edge
|
|
6
|
+
* runtimes.
|
|
7
|
+
*
|
|
8
|
+
* @module crypto-utils
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const enc = new TextEncoder();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {ArrayBuffer | Uint8Array} buf
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function bufToHex(buf) {
|
|
18
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
19
|
+
let hex = '';
|
|
20
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
|
21
|
+
return hex;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {ArrayBuffer | Uint8Array} buf
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function bufToBase64(buf) {
|
|
29
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
30
|
+
let s = '';
|
|
31
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
32
|
+
return btoa(s);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string | ArrayBufferView | ArrayBuffer} data
|
|
37
|
+
* @returns {Uint8Array}
|
|
38
|
+
*/
|
|
39
|
+
function toBytes(data) {
|
|
40
|
+
if (typeof data === 'string') return enc.encode(data);
|
|
41
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
42
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute a hex-encoded digest of `data` under `algo`.
|
|
47
|
+
*
|
|
48
|
+
* @param {'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'} algo
|
|
49
|
+
* @param {string | ArrayBufferView | ArrayBuffer} data
|
|
50
|
+
* @returns {Promise<string>} full hex string (no truncation)
|
|
51
|
+
*/
|
|
52
|
+
export async function digestHex(algo, data) {
|
|
53
|
+
return bufToHex(await crypto.subtle.digest(algo, toBytes(data)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute a base64-encoded digest of `data` under `algo`.
|
|
58
|
+
*
|
|
59
|
+
* @param {'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'} algo
|
|
60
|
+
* @param {string | ArrayBufferView | ArrayBuffer} data
|
|
61
|
+
* @returns {Promise<string>}
|
|
62
|
+
*/
|
|
63
|
+
export async function digestBase64(algo, data) {
|
|
64
|
+
return bufToBase64(await crypto.subtle.digest(algo, toBytes(data)));
|
|
65
|
+
}
|