@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 +1 -1
- package/src/context.js +3 -3
- package/src/dev/server.js +6 -1
- package/src/dev/watch-ignore.js +124 -0
- package/src/importmap.js +4 -8
- package/src/rate-limit.js +0 -6
- package/src/session.js +1 -1
- package/src/ssr/head.js +36 -14
- package/src/ssr/render.js +4 -4
- package/src/ssr/responses.js +4 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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
|
|
300
|
-
*
|
|
301
|
-
*
|
|
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.
|
|
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
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:
|
|
439
|
-
//
|
|
440
|
-
//
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
package/src/ssr/responses.js
CHANGED
|
@@ -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 (
|
|
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(
|
|
27
|
+
export function getNonce() {
|
|
32
28
|
return cspNonce() || undefined;
|
|
33
29
|
}
|
|
34
30
|
|