@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.
@@ -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 at server boot. Results are used to prime the core
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 src;
80
- try { src = await readFile(file, 'utf8'); } catch { continue; }
81
- const comps = extractComponents(src);
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
- const components = await scanComponents(appDir);
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
+ }