@webjsdev/server 0.8.84 → 0.8.86

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.84",
3
+ "version": "0.8.86",
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/serve.js CHANGED
@@ -361,7 +361,15 @@ export async function handleCore(req, ctx) {
361
361
 
362
362
  // Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
363
363
  if (method === 'GET' && state.routeTable.metadataRoutes) {
364
- const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path);
364
+ const meta = state.routeTable.metadataRoutes.find((r) => r.urlPath === path)
365
+ || (path === '/favicon.ico' ? faviconFallback(state.routeTable.metadataRoutes) : undefined);
366
+ if (meta && meta.static) {
367
+ // A static metadata file (`app/icon.svg`, `app/manifest.webmanifest`):
368
+ // the file's bytes with the convention's content type.
369
+ const res = await fileResponse(meta.file, { dev, immutable: versioned });
370
+ if (res.ok && meta.contentType) res.headers.set('content-type', meta.contentType);
371
+ return res;
372
+ }
365
373
  if (meta) {
366
374
  try {
367
375
  // The route gets `{ request, url, siteUrl, pages }` and may return a
@@ -633,6 +641,26 @@ export async function loadMiddleware(appDir, dev, logger) {
633
641
  * @param {string} abs
634
642
  * @param {{ dev: boolean, immutable: boolean }} opts
635
643
  */
644
+ /**
645
+ * What answers `/favicon.ico` when neither `public/favicon.ico` nor
646
+ * `app/favicon.ico` exists: the app's own icon. Browsers and crawlers request
647
+ * `/favicon.ico` without reading the page's <head> (a feed reader, a bookmark
648
+ * import, a page served without HTML), so an app with only `app/icon.svg`
649
+ * would otherwise answer them with a 404. An `.ico` or raster file is
650
+ * preferred over SVG, which some of those clients cannot read; the dynamic
651
+ * `app/icon.ts` route is the last resort.
652
+ *
653
+ * @param {Array<{ stem: string, urlPath: string, static?: boolean, contentType?: string }>} routes
654
+ */
655
+ export function faviconFallback(routes) {
656
+ const icons = routes.filter((r) => r.stem === 'icon' && (r.static || r.urlPath === '/icon'));
657
+ const rank = (/** @type {typeof icons[number]} */ r) => !r.static ? 9
658
+ : r.contentType === 'image/x-icon' ? 0
659
+ : r.contentType === 'image/png' ? 1
660
+ : r.contentType === 'image/svg+xml' ? 3 : 2;
661
+ return icons.sort((a, b) => rank(a) - rank(b))[0];
662
+ }
663
+
636
664
  export async function fileResponse(abs, opts) {
637
665
  try {
638
666
  let data = await readFile(abs);
package/src/dev/server.js CHANGED
@@ -273,7 +273,9 @@ export async function startServer(opts) {
273
273
  // shells via listener-core.js, so live-reload + the dev error overlay behave
274
274
  // identically on both). Built before the handler so its onReload / onDevError
275
275
  // callbacks can fan out through it.
276
- const hub = new SseHub();
276
+ // A Bun hot host, or a server outside the dev supervisor (`--no-hot`, an
277
+ // embedder), applies an edit in place; a supervised Node child is restarted.
278
+ const hub = new SseHub({ inPlace: dev && (!!hotKey || process.env.__WEBJS_DEV_CHILD !== '1') });
277
279
  // Pages render the stream's state into a meta tag (#1516), so the relay can
278
280
  // re-check a page against the server on every reconnect.
279
281
  if (dev) setDevReloadState(() => ({ boot: DEV_BOOT_ID, seq: hub.seq }));
@@ -36,6 +36,18 @@
36
36
  */
37
37
  export const RELOAD_QUIET_MS = 2000;
38
38
 
39
+ /**
40
+ * The quiet window for a batch made only of in-process frames from a server
41
+ * that reloads IN PLACE (#1575): Bun's one long-lived dev server, or any
42
+ * `--no-hot` server. No restart follows such a frame, so nothing is worth
43
+ * waiting 2000ms for, and that wait was most of the save-to-paint time (3.8s
44
+ * measured in a hosted preview). 300ms still folds a save's few frames and a
45
+ * quick burst into one reload. A batch holding any restart signal (a changed
46
+ * boot id, a stale page, a frame from a restarting server) keeps the full
47
+ * `RELOAD_QUIET_MS`, since the restarted process is still warming.
48
+ */
49
+ export const RELOAD_QUIET_IN_PLACE_MS = 300;
50
+
39
51
  /**
40
52
  * The longest a reload is ever held, measured from the FIRST signal of a batch
41
53
  * (#1397). A quiet window alone would freeze the page on stale content for a
@@ -116,6 +128,19 @@ export function pageIsStale(page, boot, seq) {
116
128
  }
117
129
 
118
130
  /** A reload frame's `seq` (#1507), or null when it carries none. @param {string} data */
131
+ /**
132
+ * Whether a `reload` frame comes from a server that reloads in place (#1575).
133
+ * Absent or unreadable means it may restart, the safe (slower) reading.
134
+ * @param {string} data
135
+ * @returns {boolean}
136
+ */
137
+ export function frameInPlace(data) {
138
+ try {
139
+ const o = JSON.parse(data);
140
+ return !!(o && o.inPlace === true);
141
+ } catch (_) { return false; }
142
+ }
143
+
119
144
  function reloadSeq(data) {
120
145
  try {
121
146
  const o = JSON.parse(data);
@@ -190,6 +215,8 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
190
215
  let batchAll = false;
191
216
  /** @type {Set<any>} tabs whose page fell behind, reloaded on their own */
192
217
  const batchPorts = new Set();
218
+ /** Whether the batch holds a signal from a server that restarts (#1575). */
219
+ let batchRestart = false;
193
220
 
194
221
  function emitReload() {
195
222
  if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
@@ -200,6 +227,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
200
227
  const targets = batchAll ? Array.from(ports.keys()) : Array.from(batchPorts).filter((p) => ports.has(p));
201
228
  batchAll = false;
202
229
  batchPorts.clear();
230
+ batchRestart = false;
203
231
  for (const p of targets) {
204
232
  // The tab applies this reload, so from here its page reflects the state
205
233
  // the relay knows now; a later hello compares against that.
@@ -219,11 +247,13 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
219
247
  * @param {string} verdict
220
248
  * @param {any} [port] reload only this tab (its page fell behind); omitted, every tab
221
249
  */
222
- function requestReload(verdict, port) {
250
+ function requestReload(verdict, port, inPlace) {
223
251
  if (port === undefined) batchAll = true; else batchPorts.add(port);
224
252
  if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
253
+ // One restart signal anywhere in the batch keeps the long window (#1575).
254
+ if (!inPlace) batchRestart = true;
225
255
  if (quietTimer !== null) timers.clearTimeout(quietTimer);
226
- quietTimer = timers.setTimeout(emitReload, RELOAD_QUIET_MS);
256
+ quietTimer = timers.setTimeout(emitReload, batchRestart ? RELOAD_QUIET_MS : RELOAD_QUIET_IN_PLACE_MS);
227
257
  if (capTimer === null) capTimer = timers.setTimeout(emitReload, RELOAD_MAX_HOLD_MS);
228
258
  }
229
259
 
@@ -278,7 +308,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
278
308
  lastError = null;
279
309
  const n = reloadSeq(e.data);
280
310
  if (n !== null) lastSeq = n;
281
- requestReload(parseVerdict(e.data));
311
+ requestReload(parseVerdict(e.data), undefined, frameInPlace(e.data));
282
312
  });
283
313
  es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
284
314
  // The stream dropped (a restarting server, a network blip, a host that
@@ -351,7 +351,13 @@ export function loadWsModule(file, dev) {
351
351
  * tab was hidden can tell, on reconnecting, that it missed an edit.
352
352
  */
353
353
  export class SseHub {
354
- constructor() {
354
+ /**
355
+ * @param {{ inPlace?: boolean }} [opts] `inPlace`: this server applies an
356
+ * edit without restarting its process (#1575), so its reload frames say so
357
+ * and the browser relay need not wait for a restart to settle.
358
+ */
359
+ constructor({ inPlace = false } = {}) {
360
+ this.inPlace = inPlace;
355
361
  /** @type {Set<{ send: (s: string) => void, close: () => void }>} */
356
362
  this.clients = new Set();
357
363
  /** Reload frames sent by this process (#1507). */
@@ -399,7 +405,7 @@ export class SseHub {
399
405
  reload(verdict) {
400
406
  const v = verdict && typeof verdict.v === 'string' ? verdict : { v: 'reload' };
401
407
  this.seq++;
402
- this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq })}\n\n`);
408
+ this._raw(`event: reload\ndata: ${JSON.stringify({ ...v, seq: this.seq, ...(this.inPlace ? { inPlace: true } : {}) })}\n\n`);
403
409
  }
404
410
 
405
411
  /** Push a dev-error overlay frame (#264) to every open tab. @param {object} frame */
package/src/router.js CHANGED
@@ -26,7 +26,7 @@ import { findInstrumentationClient } from './instrumentation.js';
26
26
  * middlewares: string[],
27
27
  * }} ApiRoute
28
28
  *
29
- * @typedef {{ stem: string, file: string, urlPath: string }} MetadataRoute
29
+ * @typedef {{ stem: string, file: string, urlPath: string, static?: boolean, contentType?: string }} MetadataRoute
30
30
  *
31
31
  * @typedef {{
32
32
  * pages: PageRoute[],
@@ -60,6 +60,10 @@ import { findInstrumentationClient } from './instrumentation.js';
60
60
  * app/sitemap.js → serves /sitemap.xml
61
61
  * app/robots.js → serves /robots.txt
62
62
  * app/icon.js → serves /icon (dynamic)
63
+ * app/icon.svg | icon.png → serves /icon.svg | /icon.png (static file)
64
+ * app/apple-icon.png → serves /apple-icon.png (static file)
65
+ * app/favicon.ico → serves /favicon.ico (static file)
66
+ * app/manifest.webmanifest → serves /manifest.webmanifest (static file)
63
67
  * app/opengraph-image.js → serves /opengraph-image (dynamic)
64
68
  *
65
69
  * @param {string} appDir
@@ -114,6 +118,19 @@ export async function buildRouteTable(appDir) {
114
118
  // Private folders (any segment starting with _) are excluded from routing.
115
119
  if (dir !== '.' && dir.split('/').some((s) => s.startsWith('_'))) continue;
116
120
 
121
+ // Static metadata FILES at the app root (`app/icon.svg`, `app/icon.png`,
122
+ // `app/apple-icon.png`, `app/favicon.ico`, `app/manifest.webmanifest`):
123
+ // served as is at `/<file name>` and auto-linked into <head> like their
124
+ // `.ts` counterparts. Root only: an icon or manifest describes the whole
125
+ // app, and a nested copy has no meaning a browser would act on.
126
+ if (dir === '.') {
127
+ const staticMeta = staticMetadataFile(base);
128
+ if (staticMeta) {
129
+ metadataRoutes.push({ ...staticMeta, file, urlPath: '/' + base, static: true });
130
+ continue;
131
+ }
132
+ }
133
+
117
134
  // Match `<name>.<js|mjs|ts|mts>` conventions. Stem is the name without ext.
118
135
  const stem = stemOf(base);
119
136
  if (!stem) continue;
@@ -215,6 +232,37 @@ export async function buildRouteTable(appDir) {
215
232
  * @param {string} base
216
233
  * @returns {string | null}
217
234
  */
235
+ /**
236
+ * The static metadata file conventions, keyed by file name pattern. Each maps
237
+ * to the metadata stem it stands for and the content type it is served with.
238
+ * @type {Array<[RegExp, string, Record<string, string>]>}
239
+ */
240
+ const STATIC_METADATA_FILES = [
241
+ [/^icon\.(svg|png|ico|jpg|jpeg|webp|gif)$/, 'icon', {}],
242
+ [/^apple-icon\.(png|jpg|jpeg)$/, 'apple-icon', {}],
243
+ [/^favicon\.(ico)$/, 'favicon', {}],
244
+ [/^manifest\.(json|webmanifest)$/, 'manifest', { json: 'application/manifest+json', webmanifest: 'application/manifest+json' }],
245
+ ];
246
+
247
+ /** @type {Record<string, string>} */
248
+ const IMAGE_TYPES = {
249
+ svg: 'image/svg+xml', png: 'image/png', ico: 'image/x-icon', jpg: 'image/jpeg',
250
+ jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
251
+ };
252
+
253
+ /**
254
+ * Classify an app-root file name as a static metadata file, or null.
255
+ * @param {string} base
256
+ * @returns {{ stem: string, contentType: string } | null}
257
+ */
258
+ export function staticMetadataFile(base) {
259
+ for (const [re, stem, types] of STATIC_METADATA_FILES) {
260
+ const m = re.exec(base);
261
+ if (m) return { stem, contentType: types[m[1]] || IMAGE_TYPES[m[1]] };
262
+ }
263
+ return null;
264
+ }
265
+
218
266
  function stemOf(base) {
219
267
  const m = /^([A-Za-z0-9_.-]+)\.(?:m?[jt]s)$/.exec(base);
220
268
  return m ? m[1] : null;
package/src/ssr/head.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { openSync, readSync, closeSync } from 'node:fs';
1
2
  import { basePath, buildImportMap, importMapTag, vendorPreconnectOrigins } from '../importmap.js';
2
3
  import { withBasePath } from '../base-path.js';
3
4
  import { escapeAttr, escapeHtml } from './escape.js';
@@ -10,62 +11,128 @@ import { embedScriptTag } from '../dev-embed.js';
10
11
  import { devReloadState } from '../dev-reload-state.js';
11
12
  import { openGraphPairs, twitterPairs, setMetadataImageRoutes } from './seo.js';
12
13
 
13
- // Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
14
- // Set at boot and on each route rebuild from the route table, the same shape
15
- // as setClientRouterEnabled, so no opt has to thread through every render
16
- // path. Empty by default, which keeps an app that declares its icons (or has
17
- // neither route) byte-identical.
14
+ // Which icon and manifest metadata the app has: the `app/icon.*`,
15
+ // `app/apple-icon.*` and `app/manifest.*` files (a static file such as
16
+ // `app/icon.svg`, or a route such as `app/icon.ts`). Set at boot and on each
17
+ // route rebuild from the route table, the same shape as setClientRouterEnabled,
18
+ // so no opt has to thread through every render path. Empty by default, which
19
+ // keeps an app that declares its icons (or has none) byte-identical.
18
20
  //
19
21
  // This sits in head.js rather than in a module of its own the way the
20
22
  // client-router flag does: `wrapHead` is the only reader, and module state
21
23
  // belongs with the code that uses and writes it. The flag moved out only
22
24
  // because it has a second reader in render.js.
23
- /** @type {{ icon: boolean, apple: boolean }} */
24
- let _metadataIconRoutes = { icon: false, apple: false };
25
+ /** @typedef {{ url: string, type?: string, sizes?: string }} AutoIcon */
26
+ /** @type {{ icon: AutoIcon[], apple: AutoIcon[], manifest: string | null }} */
27
+ let _metadataIconRoutes = { icon: [], apple: [], manifest: null };
25
28
 
26
29
  /**
27
- * Record the icon metadata routes the app defines.
30
+ * The pixel size of a PNG file (`"180x180"`), read from its IHDR header, or
31
+ * undefined when the file is not a readable PNG. Only the first 24 bytes are
32
+ * read, once per route rebuild.
33
+ * @param {string} file
34
+ */
35
+ function pngSizes(file) {
36
+ let fd;
37
+ try {
38
+ fd = openSync(file, 'r');
39
+ const buf = Buffer.alloc(24);
40
+ if (readSync(fd, buf, 0, 24, 0) < 24) return undefined;
41
+ if (buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('latin1', 12, 16) !== 'IHDR') return undefined;
42
+ return `${buf.readUInt32BE(16)}x${buf.readUInt32BE(20)}`;
43
+ } catch {
44
+ return undefined;
45
+ } finally {
46
+ if (fd !== undefined) closeSync(fd);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Record the icon and manifest metadata the app defines.
52
+ *
53
+ * A static file is linked with its type, plus its pixel size for a PNG and
54
+ * `sizes="any"` for an SVG. A route (`app/icon.ts`) is linked bare: it picks
55
+ * its own content type at request time, which is the reason to use one, so a
56
+ * declared type could contradict the bytes. When an app has both a static
57
+ * icon and an icon route, the static file wins the link: the route then
58
+ * stays reachable at its URL without becoming the favicon.
59
+ *
60
+ * Raster icons are linked before SVG. Google's favicon crawler takes the
61
+ * first usable icon and wants a square raster, and a browser that reads SVG
62
+ * picks it from the list regardless of order.
28
63
  *
29
- * @param {Iterable<{ stem: string }> | null | undefined} metadataRoutes
64
+ * @param {Iterable<{ stem: string, urlPath?: string, file?: string, static?: boolean, contentType?: string }> | null | undefined} metadataRoutes
30
65
  * The route table's `metadataRoutes`, or nullish to clear.
31
66
  */
32
67
  export function setMetadataIconRoutes(metadataRoutes) {
33
- const stems = new Set();
34
- for (const r of metadataRoutes || []) if (r && r.stem) stems.add(r.stem);
35
- _metadataIconRoutes = { icon: stems.has('icon'), apple: stems.has('apple-icon') };
36
- // The share-image routes (#1564) are recorded from the same table so one
37
- // call keeps both in step on boot and on every rebuild.
68
+ /** @type {Record<string, Array<{ stem: string, urlPath?: string, file?: string, static?: boolean, contentType?: string }>>} */
69
+ const byStem = { icon: [], 'apple-icon': [], manifest: [] };
70
+ for (const r of metadataRoutes || []) {
71
+ if (!r || !byStem[r.stem]) continue;
72
+ // Only the app-root ones describe the app. A route nested in a segment
73
+ // (`app/blog/icon.ts`) answers at its own URL but is not the favicon.
74
+ const url = r.urlPath || '/' + r.stem;
75
+ if (url.lastIndexOf('/') !== 0) continue;
76
+ byStem[r.stem].push({ ...r, urlPath: url });
77
+ }
78
+ /** @param {typeof byStem.icon} list @returns {AutoIcon[]} */
79
+ const links = (list) => {
80
+ const statics = list.filter((r) => r.static);
81
+ const chosen = statics.length ? statics : list.filter((r) => !r.static);
82
+ const rank = (/** @type {typeof list[number]} */ r) => (r.contentType === 'image/svg+xml' ? 1 : 0);
83
+ return chosen.sort((a, b) => rank(a) - rank(b)).map((r) => {
84
+ /** @type {AutoIcon} */
85
+ const out = { url: /** @type {string} */ (r.urlPath) };
86
+ if (r.static && r.contentType) out.type = r.contentType;
87
+ if (r.contentType === 'image/svg+xml') out.sizes = 'any';
88
+ else if (r.contentType === 'image/png' && r.file) {
89
+ const sizes = pngSizes(r.file);
90
+ if (sizes) out.sizes = sizes;
91
+ }
92
+ return out;
93
+ });
94
+ };
95
+ const manifest = byStem.manifest.find((r) => r.static) || byStem.manifest[0];
96
+ _metadataIconRoutes = {
97
+ icon: links(byStem.icon),
98
+ apple: links(byStem['apple-icon']),
99
+ manifest: manifest ? /** @type {string} */ (manifest.urlPath) : null,
100
+ }; // The og/twitter image routes ride the same rebuild hook (seo.js).
38
101
  setMetadataImageRoutes(metadataRoutes);
39
102
  }
40
103
 
41
104
  /**
42
- * The implicit `metadata.icons` an app's icon routes stand for, or null when
43
- * it has none. Base-path prefixed, because that is where the routes are
105
+ * The implicit `metadata.icons` an app's icon files and routes stand for, or
106
+ * null when it has none. Base-path prefixed, because that is where they are
44
107
  * SERVED: the listener strips the base path before matching, so under
45
- * `webjs.basePath` the route answers at `<basePath>/icon`. A user-authored
108
+ * `webjs.basePath` the icon answers at `<basePath>/icon.svg`. A user-authored
46
109
  * `icons` URL is deliberately left alone (it may be cross-origin, and the
47
110
  * author writes the path they mean), so only these framework-emitted ones
48
111
  * are prefixed.
49
112
  *
50
- * No `type` or `sizes` is emitted. A metadata route picks its own content
51
- * type at request time, which is the reason to use one, so declaring a type
52
- * here could contradict the bytes; and `sizes` is unknowable without reading
53
- * the response. Both are optional in HTML, and a browser sniffs the served
54
- * content type.
55
- *
56
- * @returns {{ icon?: string, apple?: string } | null}
113
+ * @returns {{ icon?: AutoIcon[], apple?: AutoIcon[] } | null}
57
114
  */
58
115
  function autoMetadataRouteIcons() {
59
116
  const { icon, apple } = _metadataIconRoutes;
60
- if (!icon && !apple) return null;
117
+ if (!icon.length && !apple.length) return null;
61
118
  const bp = basePath();
62
- /** @type {{ icon?: string, apple?: string }} */
119
+ const prefix = (/** @type {AutoIcon} */ i) => ({ ...i, url: withBasePath(i.url, bp) });
120
+ /** @type {{ icon?: AutoIcon[], apple?: AutoIcon[] }} */
63
121
  const out = {};
64
- if (icon) out.icon = withBasePath('/icon', bp);
65
- if (apple) out.apple = withBasePath('/apple-icon', bp);
122
+ if (icon.length) out.icon = icon.map(prefix);
123
+ if (apple.length) out.apple = apple.map(prefix);
66
124
  return out;
67
125
  }
68
126
 
127
+ /**
128
+ * The app's `app/manifest.*` URL (base-path prefixed), or null.
129
+ * @returns {string | null}
130
+ */
131
+ function autoManifestUrl() {
132
+ const { manifest } = _metadataIconRoutes;
133
+ return manifest ? withBasePath(manifest, basePath()) : null;
134
+ }
135
+
69
136
  /**
70
137
  * HTML-safe-escape a JSON string for embedding inside a
71
138
  * `<script type="application/ld+json">` element.
@@ -787,9 +854,14 @@ export function wrapHead(opts) {
787
854
  }
788
855
  }
789
856
 
790
- // manifest: a string URL → <link rel="manifest">
857
+ // manifest: a string URL → <link rel="manifest">. With none declared, an
858
+ // `app/manifest.*` file or route is linked automatically, the same way the
859
+ // icons are; `manifest: null` opts out.
791
860
  if (typeof m.manifest === 'string') {
792
861
  linkTags.push(`<link rel="manifest" href="${escapeAttr(absUrl(m.manifest))}">`);
862
+ } else if (m.manifest === undefined) {
863
+ const auto = autoManifestUrl();
864
+ if (auto) linkTags.push(`<link rel="manifest" href="${escapeAttr(auto)}">`);
793
865
  }
794
866
 
795
867
  // alternates: { canonical, languages: { '<hreflang>': url }, media: { '<media>': url } }