@webjsdev/server 0.8.85 → 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 +1 -1
- package/src/dev/serve.js +29 -1
- package/src/router.js +49 -1
- package/src/ssr/head.js +101 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
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/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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
/** @
|
|
24
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
43
|
-
* it has none. Base-path prefixed, because that is where
|
|
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
|
|
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
|
-
*
|
|
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 {{
|
|
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 =
|
|
65
|
-
if (apple) out.apple =
|
|
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 } }
|