glashjs 0.7.2 → 0.9.0
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/README.md +7 -3
- package/package.json +1 -1
- package/src/server/html.mjs +4 -2
- package/src/server/nav-client.mjs +55 -18
- package/src/server/server.mjs +109 -16
package/README.md
CHANGED
|
@@ -59,6 +59,8 @@ routes/
|
|
|
59
59
|
blog/[slug].mjs -> /blog/:slug (dynamic segment -> ctx.params.slug)
|
|
60
60
|
docs/[...path].mjs -> /docs/* (catch-all)
|
|
61
61
|
api/hello.mjs -> /api/hello (API: export GET/POST/…)
|
|
62
|
+
404.jsx -> custom not-found page (any unmatched path -> 404)
|
|
63
|
+
500.jsx -> custom error page (rendered in production on a throw)
|
|
62
64
|
```
|
|
63
65
|
|
|
64
66
|
```js
|
|
@@ -114,7 +116,7 @@ export default function Counter({ start = 0 }) {
|
|
|
114
116
|
|
|
115
117
|
Hydration is **CSP-safe**: server props ride in a non-executed `<script type="application/json">` and the hydration bundle is an external `'self'` module with a per-request **nonce** — so the strict CSP stays intact (no `'unsafe-inline'`).
|
|
116
118
|
|
|
117
|
-
**Nested layouts** (`_layout.jsx` in any routes dir wrap pages root→leaf, server + hydration), **streaming SSR** (the shell flushes before the component renders — `Transfer-Encoding: chunked`), and **
|
|
119
|
+
**Nested layouts** (`_layout.jsx` in any routes dir wrap pages root→leaf, server + hydration), **streaming SSR** (the shell flushes before the component renders — `Transfer-Encoding: chunked`), and **instant HMR** (`glash dev` does an in-place soft re-render on save over SSE — no full reload, no flash, and scroll/focus/form-input are preserved across the swap) are all in. **Honest scope:** uses Preact (React-compatible via `preact/compat`), not React; HMR preserves DOM/scroll/input state but **not** component `useState` (that's React-Fast-Refresh via `@prefresh`, still ahead); streaming is shell-flush, not Suspense-chunked.
|
|
118
120
|
|
|
119
121
|
## Usage
|
|
120
122
|
|
|
@@ -167,14 +169,16 @@ animatedFavicon: true, // bundled animated glash mark (d
|
|
|
167
169
|
- [x] Client-JS bundling (esbuild) per route
|
|
168
170
|
- [x] Nested layouts (`_layout.jsx` composing root→leaf, server + hydration)
|
|
169
171
|
- [x] Streaming SSR (shell flushed before the component renders)
|
|
170
|
-
- [x]
|
|
172
|
+
- [x] Instant HMR — in-place soft re-render on save (no full reload, no flash; preserves scroll, focus, and form input)
|
|
171
173
|
- [x] `<Image>` — zero-config `<picture>` with AVIF/WebP from the optimizer (beats next/image: no runtime image server)
|
|
172
174
|
- [x] `<Video>` — `<video>` with AV1/WebM + mp4 fallback + auto poster
|
|
173
175
|
- [x] File-based middleware (`_middleware.mjs`, root→leaf) — auth, redirects, headers
|
|
174
176
|
- [x] Production route precompile (`glash build` bakes server modules + minified client bundles → no runtime esbuild on `glash serve`)
|
|
175
177
|
- [x] SEO metadata API (`export const metadata` → title, description, Open Graph, Twitter cards)
|
|
176
178
|
- [x] `<Link>` client-side navigation (SPA swap of `#glash-root` + re-hydrate; progressive-enhancement `<a>`)
|
|
177
|
-
- [
|
|
179
|
+
- [x] `glash deploy` → glashdb (builds, then hands off to the `glashdb` CLI)
|
|
180
|
+
- [x] Production-grade runtime — custom `404`/`500` routes, dev error overlay, HEAD support, Range requests + streamed static (video seeking), graceful mid-stream error handling
|
|
181
|
+
- [ ] React-Fast-Refresh (`useState` preservation via `@prefresh`), Suspense streaming, edge adapter
|
|
178
182
|
- [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
|
|
179
183
|
- [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
|
|
180
184
|
- [ ] `glash deploy` → glashdb hosting in one command
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glashjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "glashjs — a web framework built on top of Next.js: file-based routing, SSR, API routes, JSX components with client hydration, nested layouts, streaming SSR, a best-in-class build-time asset optimizer, offline PWA layer, animated favicon, and secure-by-default headers. Zero mandatory dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/server/html.mjs
CHANGED
|
@@ -57,9 +57,11 @@ function shellTail({ offline = true, animatedFavicon = true, dev = false, nonce
|
|
|
57
57
|
const off = offline
|
|
58
58
|
? `<script type="module"${n}>try{const m=await import("/glash-offline.mjs");m.registerGlashOffline&&m.registerGlashOffline();}catch{}</script>`
|
|
59
59
|
: '';
|
|
60
|
-
// Dev
|
|
60
|
+
// Dev HMR: a nonce'd inline script (CSP-safe). On a change event it does an
|
|
61
|
+
// in-place soft refresh (no full reload, keeps scroll/focus/inputs), falling
|
|
62
|
+
// back to a full reload if the nav runtime hasn't loaded yet.
|
|
61
63
|
const hmr = dev
|
|
62
|
-
? `<script${n}>try{new EventSource("/_glash/hmr").onmessage=function(e){if(e.data==="reload")location.reload();};}catch(e){}</script>`
|
|
64
|
+
? `<script${n}>try{new EventSource("/_glash/hmr").onmessage=function(e){if(e.data==="reload"){if(window.__glashSoftRefresh){window.__glashSoftRefresh();}else{location.reload();}}};}catch(e){}</script>`
|
|
63
65
|
: '';
|
|
64
66
|
// Client-side navigation runtime (external 'self' module, CSP-safe).
|
|
65
67
|
const nav = '<script type="module" src="/_glash/nav.js"></script>';
|
|
@@ -1,35 +1,72 @@
|
|
|
1
|
-
// Source for /_glash/nav.js — the client-side navigation runtime.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
// Source for /_glash/nav.js — the client-side navigation + dev HMR runtime.
|
|
2
|
+
//
|
|
3
|
+
// Navigation: intercepts <a data-glash-link> clicks, fetches the route as a
|
|
4
|
+
// partial (X-Glash-Nav: 1 -> JSON { title, html, props, bundle }), swaps
|
|
5
|
+
// #glash-root, updates <title> + history, and re-hydrates by importing the
|
|
6
|
+
// route's bundle. Falls back to a full navigation on any error.
|
|
7
|
+
//
|
|
8
|
+
// Dev HMR (window.__glashSoftRefresh): on a file change the dev server pushes
|
|
9
|
+
// a reload over SSE; instead of a full page reload, we re-render the current
|
|
10
|
+
// route in place — no white flash — and restore scroll, focus, and form input
|
|
11
|
+
// across the swap. (Note: component useState resets on re-render; preserving
|
|
12
|
+
// that is React-Fast-Refresh territory, which needs @prefresh.)
|
|
13
|
+
export const NAV_CLIENT = `// glashjs client navigation + dev HMR
|
|
14
|
+
async function navigate(href, push, keepScroll) {
|
|
15
|
+
var data;
|
|
9
16
|
try {
|
|
10
|
-
|
|
17
|
+
var res = await fetch(href, { headers: { 'X-Glash-Nav': '1' }, credentials: 'same-origin' });
|
|
11
18
|
if (!res.ok) throw 0;
|
|
12
19
|
data = await res.json();
|
|
13
|
-
} catch { location.href = href; return; }
|
|
14
|
-
|
|
20
|
+
} catch (e) { location.href = href; return; }
|
|
21
|
+
var root = document.getElementById('glash-root');
|
|
15
22
|
if (!root || !data || typeof data.html !== 'string') { location.href = href; return; }
|
|
16
23
|
if (data.title) document.title = data.title;
|
|
17
|
-
|
|
24
|
+
var pe = document.getElementById('glash-props');
|
|
18
25
|
if (!pe) { pe = document.createElement('script'); pe.type = 'application/json'; pe.id = 'glash-props'; document.head.appendChild(pe); }
|
|
19
26
|
pe.textContent = JSON.stringify(data.props || {});
|
|
20
27
|
root.innerHTML = data.html;
|
|
21
28
|
if (push) history.pushState({ glash: 1 }, '', href);
|
|
22
|
-
window.scrollTo(0, 0);
|
|
23
|
-
if (data.bundle) { try { await import(data.bundle); } catch {} }
|
|
29
|
+
if (!keepScroll) window.scrollTo(0, 0);
|
|
30
|
+
if (data.bundle) { try { await import(data.bundle + '?v=' + Date.now()); } catch (e) {} }
|
|
24
31
|
}
|
|
25
|
-
|
|
32
|
+
|
|
33
|
+
document.addEventListener('click', function (e) {
|
|
26
34
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
27
|
-
|
|
35
|
+
var a = e.target.closest && e.target.closest('a[data-glash-link]');
|
|
28
36
|
if (!a) return;
|
|
29
|
-
|
|
37
|
+
var href = a.getAttribute('href');
|
|
30
38
|
if (!href || a.target || /^([a-z]+:)?\\/\\//i.test(href) || href.startsWith('#') || href.startsWith('mailto:')) return;
|
|
31
39
|
e.preventDefault();
|
|
32
40
|
if (href !== location.pathname + location.search) navigate(href, true);
|
|
33
41
|
});
|
|
34
|
-
window.addEventListener('popstate', ()
|
|
42
|
+
window.addEventListener('popstate', function () { navigate(location.pathname + location.search, false); });
|
|
43
|
+
|
|
44
|
+
window.__glashNavigate = navigate;
|
|
45
|
+
|
|
46
|
+
// Dev HMR: re-render the current route in place, preserving scroll/focus/inputs.
|
|
47
|
+
window.__glashSoftRefresh = async function () {
|
|
48
|
+
var root = document.getElementById('glash-root');
|
|
49
|
+
if (!root) { location.reload(); return; }
|
|
50
|
+
var sx = window.scrollX, sy = window.scrollY;
|
|
51
|
+
var active = document.activeElement;
|
|
52
|
+
var focusName = active && active.getAttribute && active.getAttribute('name');
|
|
53
|
+
var selStart = active && active.selectionStart;
|
|
54
|
+
var selEnd = active && active.selectionEnd;
|
|
55
|
+
var values = {};
|
|
56
|
+
root.querySelectorAll('input[name],textarea[name],select[name]').forEach(function (el) {
|
|
57
|
+
values[el.name] = (el.type === 'checkbox' || el.type === 'radio') ? el.checked : el.value;
|
|
58
|
+
});
|
|
59
|
+
await navigate(location.pathname + location.search, false, true);
|
|
60
|
+
root.querySelectorAll('input[name],textarea[name],select[name]').forEach(function (el) {
|
|
61
|
+
if (Object.prototype.hasOwnProperty.call(values, el.name)) {
|
|
62
|
+
if (el.type === 'checkbox' || el.type === 'radio') el.checked = values[el.name];
|
|
63
|
+
else el.value = values[el.name];
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
window.scrollTo(sx, sy);
|
|
67
|
+
if (focusName) {
|
|
68
|
+
var el = root.querySelector('[name="' + focusName + '"]');
|
|
69
|
+
if (el) { el.focus(); try { el.selectionStart = selStart; el.selectionEnd = selEnd; } catch (e) {} }
|
|
70
|
+
}
|
|
71
|
+
};
|
|
35
72
|
`;
|
package/src/server/server.mjs
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
// service worker, favicons) with Brotli negotiation.
|
|
7
7
|
// Every response carries the secure-by-default headers.
|
|
8
8
|
import http from 'node:http';
|
|
9
|
-
import { promises as fs, existsSync, statSync, watch } from 'node:fs';
|
|
9
|
+
import { promises as fs, existsSync, statSync, watch, createReadStream } from 'node:fs';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { pathToFileURL } from 'node:url';
|
|
13
13
|
import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
|
|
14
|
-
import { renderDocument, documentParts, renderMeta } from './html.mjs';
|
|
14
|
+
import { renderDocument, documentParts, renderMeta, escapeHtml } from './html.mjs';
|
|
15
15
|
import { NAV_CLIENT } from './nav-client.mjs';
|
|
16
16
|
import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId, findLayouts, clearJsxCaches } from './jsx.mjs';
|
|
17
17
|
import { securityHeaders } from '../security/headers.mjs';
|
|
@@ -78,7 +78,7 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
78
78
|
return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
79
79
|
}
|
|
80
80
|
const match = matchRoute(routes, pathname);
|
|
81
|
-
if (!match) return
|
|
81
|
+
if (!match) return await handleNotFound(res, routes, req, url, cfg, secHeaders, root, routesDir, dev);
|
|
82
82
|
const ctx = makeCtx(req, res, url, match.params);
|
|
83
83
|
// Run the middleware chain (root -> leaf). Any return value short-circuits.
|
|
84
84
|
for (const mwFile of findMiddleware(routesDir, match.route.file)) {
|
|
@@ -88,6 +88,10 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
88
88
|
const result = await mw(ctx);
|
|
89
89
|
if (result) return sendMiddlewareResult(res, result, secHeaders);
|
|
90
90
|
}
|
|
91
|
+
// HEAD: same status/headers as GET, no body (cheap — skip rendering).
|
|
92
|
+
if (req.method === 'HEAD') {
|
|
93
|
+
return send(res, 200, match.route.isApi ? 'application/json' : 'text/html; charset=utf-8', '', secHeaders);
|
|
94
|
+
}
|
|
91
95
|
if (match.route.isApi) {
|
|
92
96
|
const mod = await importRoute(match.route.file);
|
|
93
97
|
return await handleApi(res, mod, req, ctx, secHeaders);
|
|
@@ -98,8 +102,17 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
98
102
|
const mod = await importRoute(match.route.file);
|
|
99
103
|
return await handlePage(res, mod, ctx, cfg, secHeaders, dev);
|
|
100
104
|
} catch (err) {
|
|
101
|
-
|
|
102
|
-
send(res, 500, 'text/
|
|
105
|
+
if (res.headersSent) return res.end(); // error mid-stream — can't replace headers
|
|
106
|
+
if (dev) return send(res, 500, 'text/html; charset=utf-8', devErrorOverlay(err), secHeaders);
|
|
107
|
+
try {
|
|
108
|
+
const r500 = routes.find((r) => r.pattern === '/500');
|
|
109
|
+
if (r500) {
|
|
110
|
+
const ctx = makeCtx(req, res, url, {});
|
|
111
|
+
const { html, nonce } = await renderStandalone(r500, ctx, cfg, root, routesDir, dev);
|
|
112
|
+
return send(res, 500, 'text/html; charset=utf-8', html, pageHeaders(cfg, secHeaders, nonce));
|
|
113
|
+
}
|
|
114
|
+
} catch { /* fall back to the default page */ }
|
|
115
|
+
send(res, 500, 'text/html; charset=utf-8', defaultErrorHtml(500, 'Something went wrong'), secHeaders);
|
|
103
116
|
}
|
|
104
117
|
});
|
|
105
118
|
|
|
@@ -166,9 +179,18 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
|
|
|
166
179
|
});
|
|
167
180
|
res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
|
|
168
181
|
res.write(open); // flush the shell first, before rendering the component
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
try {
|
|
183
|
+
const rendered = await renderComponent(mod, props);
|
|
184
|
+
res.write(`<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${id}.js"></script>`);
|
|
185
|
+
res.end(tail);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
// Shell is already on the wire, so we can't change the status — surface the
|
|
188
|
+
// error inside the stream (full overlay in dev, a clean message in prod).
|
|
189
|
+
res.write(dev
|
|
190
|
+
? `<pre style="color:#ff6b6b;white-space:pre-wrap;font:13px ui-monospace,monospace;padding:1rem">${escapeHtml(String(err?.stack || err))}</pre>`
|
|
191
|
+
: '<p style="font:16px system-ui;color:#9aa0aa;padding:1rem">Something went wrong rendering this page.</p>');
|
|
192
|
+
res.end(tail);
|
|
193
|
+
}
|
|
172
194
|
}
|
|
173
195
|
|
|
174
196
|
// metadata export may be a plain object or a (ctx) => object function.
|
|
@@ -194,20 +216,91 @@ async function serveStatic(res, outDir, pathname, req, secHeaders) {
|
|
|
194
216
|
const rel = pathname.replace(/^\/+/, '');
|
|
195
217
|
const file = path.join(outDir, rel);
|
|
196
218
|
if (!file.startsWith(outDir + path.sep)) return false; // path traversal guard
|
|
219
|
+
const head = req.method === 'HEAD';
|
|
220
|
+
const range = req.headers.range;
|
|
197
221
|
const ae = String(req.headers['accept-encoding'] || '');
|
|
198
|
-
|
|
222
|
+
|
|
223
|
+
// Brotli precompressed sibling — only when the client isn't asking for a byte range.
|
|
224
|
+
if (!range && ae.includes('br') && existsSync(file + '.br')) {
|
|
199
225
|
const buf = await fs.readFile(file + '.br');
|
|
200
|
-
res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'content-encoding': 'br', 'cache-control': 'public, max-age=31536000, immutable' });
|
|
201
|
-
res.end(buf);
|
|
226
|
+
res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'content-encoding': 'br', vary: 'Accept-Encoding', 'cache-control': 'public, max-age=31536000, immutable' });
|
|
227
|
+
res.end(head ? undefined : buf);
|
|
202
228
|
return true;
|
|
203
229
|
}
|
|
204
|
-
if (existsSync(file) && statSync(file).isFile())
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
230
|
+
if (!(existsSync(file) && statSync(file).isFile())) return false;
|
|
231
|
+
|
|
232
|
+
const stat = statSync(file);
|
|
233
|
+
const ct = mime(file);
|
|
234
|
+
// Range requests (video/audio seeking) -> 206 Partial Content.
|
|
235
|
+
if (range) {
|
|
236
|
+
const m = /bytes=(\d*)-(\d*)/.exec(range);
|
|
237
|
+
const start = m && m[1] ? parseInt(m[1], 10) : 0;
|
|
238
|
+
const end = m && m[2] ? parseInt(m[2], 10) : stat.size - 1;
|
|
239
|
+
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) {
|
|
240
|
+
res.writeHead(416, { ...secHeaders, 'content-range': `bytes */${stat.size}` });
|
|
241
|
+
res.end();
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
res.writeHead(206, { ...secHeaders, 'content-type': ct, 'accept-ranges': 'bytes', 'content-range': `bytes ${start}-${end}/${stat.size}`, 'content-length': end - start + 1, 'cache-control': 'public, max-age=3600' });
|
|
245
|
+
if (head) { res.end(); return true; }
|
|
246
|
+
createReadStream(file, { start, end }).pipe(res);
|
|
208
247
|
return true;
|
|
209
248
|
}
|
|
210
|
-
|
|
249
|
+
// Full file — streamed (handles large assets without buffering them in memory).
|
|
250
|
+
res.writeHead(200, { ...secHeaders, 'content-type': ct, 'accept-ranges': 'bytes', 'content-length': stat.size, 'cache-control': 'public, max-age=3600' });
|
|
251
|
+
if (head) { res.end(); return true; }
|
|
252
|
+
createReadStream(file).pipe(res);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Render a 404/500 route (or any route) to a full HTML document string. */
|
|
257
|
+
async function renderStandalone(route, ctx, cfg, root, routesDir, dev) {
|
|
258
|
+
const nonce = randomBytes(16).toString('base64');
|
|
259
|
+
if (isComponentRoute(route.file)) {
|
|
260
|
+
const layouts = findLayouts(routesDir, route.file);
|
|
261
|
+
const mod = await loadComponentRoute(route.file, layouts, root, dev);
|
|
262
|
+
const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
|
|
263
|
+
const meta = await resolveMeta(mod.metadata, ctx);
|
|
264
|
+
const rendered = await renderComponent(mod, props);
|
|
265
|
+
const head = renderMeta(meta) + `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
|
|
266
|
+
const body = `<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${routeId(route.file)}.js"></script>`;
|
|
267
|
+
return { html: renderDocument({ title: meta.title || mod.title || cfg.name, head, body, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev }), nonce };
|
|
268
|
+
}
|
|
269
|
+
const mod = await import(pathToFileURL(route.file).href + (dev ? `?t=${Date.now()}` : ''));
|
|
270
|
+
const out = typeof mod.default === 'function' ? await mod.default(ctx) : '';
|
|
271
|
+
const page = typeof out === 'string' || (out && out.__raw) ? { body: out } : (out || {});
|
|
272
|
+
const meta = await resolveMeta(page.metadata || mod.metadata, ctx);
|
|
273
|
+
return { html: renderDocument({ title: meta.title || page.title || mod.title || cfg.name, head: renderMeta(meta) + (page.head ? (page.head.__raw || page.head) : ''), body: page.body ?? '', offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev }), nonce };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 404: render a custom `404` route if present, else a clean default page. */
|
|
277
|
+
async function handleNotFound(res, routes, req, url, cfg, secHeaders, root, routesDir, dev) {
|
|
278
|
+
const r404 = routes.find((r) => r.pattern === '/404');
|
|
279
|
+
if (r404) {
|
|
280
|
+
try {
|
|
281
|
+
const ctx = makeCtx(req, res, url, {});
|
|
282
|
+
const { html, nonce } = await renderStandalone(r404, ctx, cfg, root, routesDir, dev);
|
|
283
|
+
return send(res, 404, 'text/html; charset=utf-8', req.method === 'HEAD' ? '' : html, pageHeaders(cfg, secHeaders, nonce));
|
|
284
|
+
} catch { /* fall back to default */ }
|
|
285
|
+
}
|
|
286
|
+
send(res, 404, 'text/html; charset=utf-8', req.method === 'HEAD' ? '' : defaultErrorHtml(404, 'Page not found'), secHeaders);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function defaultErrorHtml(status, message) {
|
|
290
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${status}</title></head>
|
|
291
|
+
<body style="font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0d12;color:#e8eaed">
|
|
292
|
+
<main style="text-align:center;padding:2rem"><h1 style="font-size:3rem;margin:0;color:#22c55e">${status}</h1><p style="color:#9aa0aa">${escapeHtml(message)}</p></main>
|
|
293
|
+
</body></html>`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function devErrorOverlay(err) {
|
|
297
|
+
const stack = escapeHtml(String(err?.stack || err));
|
|
298
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>glashjs — error</title></head>
|
|
299
|
+
<body style="font:14px ui-monospace,SFMono-Regular,Menlo,monospace;background:#1a0f12;color:#ffd7d7;margin:0;padding:2rem">
|
|
300
|
+
<h1 style="color:#ff6b6b;font-size:1.1rem">⚠ glashjs runtime error</h1>
|
|
301
|
+
<pre style="white-space:pre-wrap;background:#0a0608;border:1px solid #3a1f25;border-radius:8px;padding:1rem;overflow:auto">${stack}</pre>
|
|
302
|
+
<p style="color:#9aa0aa">This overlay shows only in dev. Production renders a clean 500 page (or your <code>routes/500.jsx</code>).</p>
|
|
303
|
+
</body></html>`;
|
|
211
304
|
}
|
|
212
305
|
|
|
213
306
|
function makeCtx(req, res, url, params) {
|