@webjsdev/server 0.8.87 → 0.8.88

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.88",
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/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/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 = [];