@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 +1 -1
- package/src/dev/server.js +6 -1
- package/src/dev/watch-ignore.js +124 -0
- package/src/ssr/head.js +33 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
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 = [];
|