@webjsdev/server 0.8.87 → 0.8.89

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.87",
3
+ "version": "0.8.89",
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",
package/src/context.js CHANGED
@@ -178,9 +178,9 @@ setCspNonceProvider(() => {
178
178
  return match ? match[1] : '';
179
179
  });
180
180
 
181
- // Re-export for backwards-compat: callers that imported cspNonce from
182
- // @webjsdev/server still work. New code should import from
183
- // @webjsdev/core for browser-isomorphism.
181
+ // `cspNonce` is also reachable from @webjsdev/server for server-only modules
182
+ // (route handlers, actions); a layout or page imports it from @webjsdev/core
183
+ // so the module stays browser-isomorphic.
184
184
  export { cspNonce };
185
185
 
186
186
  /**
package/src/dev/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { strongerVerdict } from '../dev-classify.js';
3
+ import { createWatchIgnore } from './watch-ignore.js';
3
4
  import { watchTree } from './watch-tree.js';
4
5
  import { relative, resolve } from 'node:path';
5
6
  import {
@@ -352,8 +353,12 @@ export async function startServer(opts) {
352
353
  logger.warn(`file watcher skipped ${err && err.path ? err.path : 'a path'} (${err && err.code ? err.code : String(err)})`);
353
354
  };
354
355
  const watchRoot = (root) => {
356
+ // Output nothing serves (`*.log`, coverage/, ...) and the root's
357
+ // `.gitignore` never reload the page: `npm run dev > dev.log` made every
358
+ // log line a reload (watch-ignore.js).
359
+ const unserved = createWatchIgnore(root);
355
360
  const close = watchTree(root, {
356
- ignore: shouldIgnoreWatchPath,
361
+ ignore: (rel) => shouldIgnoreWatchPath(rel) || unserved(rel),
357
362
  onEvent: (filename) => {
358
363
  // A regenerate output (#967) is a build product the server itself
359
364
  // writes on request; ignoring it stops a spurious rebuild + reload
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Which changed paths the dev watcher must NOT turn into a rebuild and a
3
+ * browser reload, beyond the framework's own artefacts (`shouldIgnoreWatchPath`
4
+ * in server.js).
5
+ *
6
+ * The dev server watches the app root, so any file written there reached the
7
+ * reload path, including files nothing serves. The common case is the server's
8
+ * own output: `npm run dev > dev.log` makes every log line a reload, which wipes
9
+ * streamed and live UI in the open page mid-check. So two more rules:
10
+ *
11
+ * - Build and tool output that is never served: `*.log`, `coverage/`, `.cache/`,
12
+ * `.nyc_output/`, `test-results/`, `playwright-report/`, `.turbo/`, editor swap
13
+ * and backup files, `.DS_Store`.
14
+ * - Whatever the app's root `.gitignore` ignores. A gitignored file is by
15
+ * definition not part of the app's source. The one exception is `.env*`,
16
+ * which is gitignored on purpose but IS input the server reads, so its edits
17
+ * keep the behaviour they had.
18
+ *
19
+ * The `.gitignore` support is the common subset: comments, blank lines, `!`
20
+ * negation, a trailing `/` for directories, a leading or inner `/` anchoring the
21
+ * pattern to the root, and the `*`, `**` and `?` wildcards. Last match wins, and
22
+ * a path is ignored when it or any directory above it is, as in git.
23
+ */
24
+ import { readFileSync, statSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ const OUTPUT = /(?:^|[\\/])(?:coverage|\.cache|\.nyc_output|test-results|playwright-report|\.turbo)(?:[\\/]|$)|\.log$|(?:^|[\\/])\.DS_Store$|\.swp$|~$/;
28
+
29
+ /**
30
+ * Tool output that is never served, whatever `.gitignore` says.
31
+ * @param {string} rel path relative to the app root
32
+ * @returns {boolean}
33
+ */
34
+ export function isUnservedOutput(rel) {
35
+ return OUTPUT.test(rel || '');
36
+ }
37
+
38
+ /**
39
+ * One `.gitignore` glob as a RegExp over a `/`-separated path.
40
+ * @param {string} glob
41
+ * @returns {RegExp}
42
+ */
43
+ function globToRegExp(glob) {
44
+ let re = '';
45
+ for (let i = 0; i < glob.length; i++) {
46
+ const c = glob[i];
47
+ if (c === '*') {
48
+ if (glob[i + 1] === '*') {
49
+ i++;
50
+ if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
51
+ } else re += '[^/]*';
52
+ } else if (c === '?') re += '[^/]';
53
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
54
+ }
55
+ return new RegExp(`^${re}$`);
56
+ }
57
+
58
+ /**
59
+ * Parse `.gitignore` text into ordered rules.
60
+ * @param {string} text
61
+ * @returns {{ re: RegExp, negate: boolean, dirOnly: boolean, anchored: boolean }[]}
62
+ */
63
+ export function parseGitignore(text) {
64
+ const rules = [];
65
+ for (let line of String(text || '').split(/\r?\n/)) {
66
+ line = line.replace(/\s+$/, '');
67
+ if (!line || line.startsWith('#')) continue;
68
+ const negate = line.startsWith('!');
69
+ if (negate) line = line.slice(1);
70
+ const dirOnly = line.endsWith('/');
71
+ if (dirOnly) line = line.slice(0, -1);
72
+ const anchored = line.includes('/');
73
+ if (line.startsWith('/')) line = line.slice(1);
74
+ if (!line) continue;
75
+ rules.push({ re: globToRegExp(line), negate, dirOnly, anchored });
76
+ }
77
+ return rules;
78
+ }
79
+
80
+ /**
81
+ * Whether `.gitignore` rules ignore `rel` (a file) or one of its directories.
82
+ * @param {ReturnType<typeof parseGitignore>} rules
83
+ * @param {string} rel
84
+ * @param {boolean} [leafIsDir] the path itself is a directory (a created dir is reported by name)
85
+ * @returns {boolean}
86
+ */
87
+ export function isGitignored(rules, rel, leafIsDir = false) {
88
+ if (!rules.length) return false;
89
+ const parts = String(rel || '').replace(/\\/g, '/').replace(/^\.\//, '').split('/').filter(Boolean);
90
+ for (let depth = 1; depth <= parts.length; depth++) {
91
+ const path = parts.slice(0, depth).join('/');
92
+ const name = parts[depth - 1];
93
+ const isDir = depth < parts.length || leafIsDir;
94
+ let ignored = false;
95
+ for (const r of rules) {
96
+ if (r.dirOnly && !isDir) continue;
97
+ if (r.anchored ? r.re.test(path) : r.re.test(name)) ignored = !r.negate;
98
+ }
99
+ // A directory git ignores is never descended into, so a negation below it
100
+ // cannot bring a file back.
101
+ if (ignored && isDir) return true;
102
+ if (depth === parts.length) return ignored;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /**
108
+ * The extra watch filter for one app root: unserved output plus that root's
109
+ * `.gitignore` (read once; `.env*` excluded).
110
+ * @param {string} root
111
+ * @returns {(rel: string) => boolean}
112
+ */
113
+ export function createWatchIgnore(root) {
114
+ let rules = [];
115
+ try { rules = parseGitignore(readFileSync(join(root, '.gitignore'), 'utf8')); } catch { /* no .gitignore */ }
116
+ return (rel) => {
117
+ const p = String(rel || '').replace(/\\/g, '/');
118
+ if (isUnservedOutput(p)) return true;
119
+ if (/(?:^|\/)\.env(?:\.[^/]*)?$/.test(p)) return false;
120
+ let dir = false;
121
+ if (rules.length) { try { dir = statSync(join(root, p)).isDirectory(); } catch { /* gone or unreadable */ } }
122
+ return isGitignored(rules, p, dir);
123
+ };
124
+ }
package/src/importmap.js CHANGED
@@ -296,11 +296,9 @@ export function vendorIntegrityFor(url) {
296
296
  * Initialized to the two minimum-safe defaults (the bare specifier
297
297
  * pointing at the browser source-mode entry and the catch-all prefix
298
298
  * pointing at `src/`) so any consumer that calls `buildImportMap()`
299
- * before `setCoreInstall` runs still gets a usable map. Pre-#118 the
300
- * legacy `coreMappings` were always derived from a boolean and so
301
- * could not be empty; this keeps that fail-open posture for embedded
302
- * SSR test helpers and one-shot tooling that imports `importmap.js`
303
- * without booting `dev.js`.
299
+ * before `setCoreInstall` runs still gets a usable map: a fail-open
300
+ * posture for embedded SSR test helpers and one-shot tooling that
301
+ * imports `importmap.js` without booting `dev.js`.
304
302
  *
305
303
  * @type {Record<string, string>}
306
304
  */
@@ -329,9 +327,7 @@ let _coreEntries = {
329
327
  * rebuilds today; if `@webjsdev/core/package.json` is edited in a
330
328
  * long-running dev session (e.g. workspace dev that runs a fresh
331
329
  * `npm run build:dist`), the derivation is refreshed on next server
332
- * restart, not on the watcher tick. Pre-#118 the legacy
333
- * `setCoreDistMode` had the same behaviour: only the dist-presence
334
- * boolean was watched, not the package.json itself.
330
+ * restart, not on the watcher tick.
335
331
  *
336
332
  * Like `setVendorEntries`, the importmap-hash is recomputed eagerly
337
333
  * so `importMapHash()` stays synchronous on the per-request SSR
package/src/rate-limit.js CHANGED
@@ -313,9 +313,3 @@ export function parseWindow(w) {
313
313
  const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[unit];
314
314
  return n * (mult || 1);
315
315
  }
316
-
317
- /** Testing hook: reset the default store (for unit tests). */
318
- export function _resetRateLimits() {
319
- // With the cache store, there's nothing to reset here: the store
320
- // handles its own state. This function exists for API compatibility.
321
- }
package/src/session.js CHANGED
@@ -235,7 +235,7 @@ export function storeSessionStorage(opts = {}) {
235
235
  };
236
236
  }
237
237
 
238
- // Backwards-compatible aliases
238
+ // Short aliases
239
239
  export const cookieSession = cookieSessionStorage;
240
240
  export const storeSession = storeSessionStorage;
241
241
 
package/src/ssr/head.js CHANGED
@@ -309,6 +309,38 @@ export function hoistHeadTags(headHtml, bodyHtml) {
309
309
  /** Internal helper re-exported for unit testing. */
310
310
  export const _hoistHeadTags = hoistHeadTags;
311
311
 
312
+ /**
313
+ * The initial-load Suspense resolver: swaps each streamed
314
+ * `<template data-webjs-resolve>` into its placeholder.
315
+ *
316
+ * Two triggers call it. Every streamed template is followed by an inline
317
+ * script that calls `__webjsResolve(id)`, which runs only after the parser has
318
+ * closed the template. A MutationObserver also catches templates inserted some
319
+ * other way. The observer must NOT act on a template the parser is still
320
+ * filling: the parser inserts the `<template>` element first and appends its
321
+ * content as bytes arrive, so when the response splits inside a boundary (a
322
+ * network chunk, or the parser yielding under load) the observer saw a
323
+ * truncated `content` and swapped in half the markup. The inline script then
324
+ * found the template already gone, so the truncation stuck (a `<muted-text>`
325
+ * with no projected text, for one). A template with a next sibling is
326
+ * complete, since the parser only adds a following node after the closing
327
+ * tag; one with none is left to its inline script.
328
+ *
329
+ * @param {string} n the ` nonce="..."` attribute, or ''
330
+ * @returns {string}
331
+ */
332
+ export function suspenseBootScript(n) {
333
+ return `<script${n}>(function(){` +
334
+ `function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
335
+ `var b=document.getElementById(id);if(t&&b){b.replaceWith(t.content.cloneNode(true));t.remove();}}` +
336
+ `window.__webjsResolve=r;` +
337
+ `if(typeof MutationObserver!=='undefined'){` +
338
+ `new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){` +
339
+ `if(n.nodeType===1&&n.tagName==='TEMPLATE'&&n.dataset.webjsResolve&&n.nextSibling){r(n.dataset.webjsResolve);}` +
340
+ `});});}).observe(document.documentElement,{childList:true,subtree:true});}` +
341
+ `})()</script>`;
342
+ }
343
+
312
344
  /**
313
345
  * Serialize a Next.js-shaped viewport object into the comma-separated
314
346
  * `content` string the meta tag expects. Recognised fields:
@@ -394,17 +426,7 @@ export function wrapHead(opts) {
394
426
  ? (reloadState ? `<meta name="webjs-dev-reload" content="${escapeAttr(JSON.stringify(reloadState))}">\n` : '') +
395
427
  `<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
396
428
  : '';
397
- const suspenseBoot = opts.streaming
398
- ? `<script${n}>(function(){` +
399
- `function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
400
- `var b=document.getElementById(id);if(t&&b){b.replaceWith(t.content.cloneNode(true));t.remove();}}` +
401
- `window.__webjsResolve=r;` +
402
- `if(typeof MutationObserver!=='undefined'){` +
403
- `new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){` +
404
- `if(n.nodeType===1&&n.tagName==='TEMPLATE'&&n.dataset.webjsResolve){r(n.dataset.webjsResolve);}` +
405
- `});});}).observe(document.documentElement,{childList:true,subtree:true});}` +
406
- `})()</script>`
407
- : '';
429
+ const suspenseBoot = opts.streaming ? suspenseBootScript(n) : '';
408
430
 
409
431
  const m = opts.metadata || {};
410
432
  const metaTags = [];
@@ -435,9 +457,9 @@ export function wrapHead(opts) {
435
457
 
436
458
  if (m.description) metaTags.push(`<meta name="description" content="${escapeAttr(m.description)}">`);
437
459
 
438
- // viewport: support string form (legacy), `metadata.viewport` object form,
439
- // and the new Next.js 14+ `export const viewport = { … }` shape captured
440
- // into `_viewport` by collectMetadata.
460
+ // viewport: the `metadata.viewport` string or object form, or the Next.js
461
+ // 14+ `export const viewport = { … }` shape captured into `_viewport` by
462
+ // collectMetadata.
441
463
  let viewportStr = '';
442
464
  if (typeof m.viewport === 'string') {
443
465
  viewportStr = m.viewport;
package/src/ssr/render.js CHANGED
@@ -830,7 +830,7 @@ async function ssrBoundaryHtml(file, heading, opts) {
830
830
  moduleUrls = [];
831
831
  }
832
832
  }
833
- const nonce = opts.req ? getNonce(opts.req) : undefined;
833
+ const nonce = getNonce();
834
834
  return wrapInDocument(body, {
835
835
  metadata: { title: heading.replace(/^\d+:\s*/, '') },
836
836
  moduleUrls,
@@ -900,7 +900,7 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
900
900
  moduleUrls = [];
901
901
  }
902
902
  }
903
- const nonce = opts.req ? getNonce(opts.req) : undefined;
903
+ const nonce = getNonce();
904
904
  return wrapInDocument(body, {
905
905
  metadata: { title: 'Not found' },
906
906
  moduleUrls,
@@ -1194,7 +1194,7 @@ export async function ssrPage(route, params, url, opts) {
1194
1194
  ),
1195
1195
  );
1196
1196
  // Extract CSP nonce from request headers (if present).
1197
- const nonce = opts.req ? getNonce(opts.req) : undefined;
1197
+ const nonce = getNonce();
1198
1198
  const wrapOpts = {
1199
1199
  metadata,
1200
1200
  moduleUrls,
@@ -1342,7 +1342,7 @@ export async function ssrPage(route, params, url, opts) {
1342
1342
  // Error paths still need to honor the request's CSP nonce so the
1343
1343
  // error page's boot scripts (when moduleUrls is non-empty) and
1344
1344
  // the meta csp-nonce tag both pass strict-CSP enforcement.
1345
- const errNonce = opts.req ? getNonce(opts.req) : undefined;
1345
+ const errNonce = getNonce();
1346
1346
  // One dedup set for the whole 500 path: the walk below and the
1347
1347
  // global-error attempt after it collapse REPEATS of the same cause (a
1348
1348
  // shared layout that fails every attempt) while still reporting a
@@ -16,19 +16,15 @@ import { escapeAttr, escapeHtml } from './escape.js';
16
16
  * scope. Delegates to `cspNonce()`, which returns the per-request nonce
17
17
  * the handler MINTED when CSP is enabled (issue #233), or, as a fallback,
18
18
  * the nonce parsed from an inbound `Content-Security-Policy` request
19
- * header (the legacy consume-only path). Using the same source as the
19
+ * header (a proxy that mints its own). Using the same source as the
20
20
  * `Content-Security-Policy` response header is what guarantees the inline
21
21
  * boot script, the importmap, the modulepreload hints, and the header all
22
- * carry the EXACT same nonce: one minted value, no drift.
22
+ * carry the EXACT same nonce: one minted value, no drift. The value comes
23
+ * from the request-scoped AsyncLocalStorage store, so it takes no request.
23
24
  *
24
- * `req` is accepted (and ignored) so existing call sites stay unchanged;
25
- * the value comes from the request-scoped AsyncLocalStorage store, not
26
- * the argument.
27
- *
28
- * @param {Request} [_req]
29
25
  * @returns {string | undefined}
30
26
  */
31
- export function getNonce(_req) {
27
+ export function getNonce() {
32
28
  return cspNonce() || undefined;
33
29
  }
34
30