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 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 **dev live-reload** (`glash dev` auto-refreshes on save over SSE) are all in. **Honest scope:** uses Preact (React-compatible via `preact/compat`), not React; live-reload is auto-refresh, not yet state-preserving fast-refresh; streaming is shell-flush, not Suspense-chunked.
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] Dev live-reload over SSE (auto-refresh on save)
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
- - [ ] State-preserving fast-refresh, Suspense streaming, `glash deploy` → glashdb
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.7.2",
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": {
@@ -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 live-reload: a nonce'd inline script (CSP-safe) that reloads on change.
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
- // Intercepts clicks on <a data-glash-link>, fetches the route as a partial
3
- // (X-Glash-Nav: 1 -> JSON { title, html, props, bundle }), swaps #glash-root,
4
- // updates the props block + <title>, pushes history, and re-hydrates by
5
- // importing the new route's bundle. Falls back to a full navigation on any error.
6
- export const NAV_CLIENT = `// glashjs client navigation
7
- async function navigate(href, push) {
8
- let data;
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
- const res = await fetch(href, { headers: { 'X-Glash-Nav': '1' }, credentials: 'same-origin' });
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
- const root = document.getElementById('glash-root');
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
- let pe = document.getElementById('glash-props');
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
- document.addEventListener('click', (e) => {
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
- const a = e.target.closest && e.target.closest('a[data-glash-link]');
35
+ var a = e.target.closest && e.target.closest('a[data-glash-link]');
28
36
  if (!a) return;
29
- const href = a.getAttribute('href');
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', () => navigate(location.pathname + location.search, false));
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
  `;
@@ -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 send(res, 404, 'text/plain; charset=utf-8', 'Not found', secHeaders);
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
- const msg = dev ? `glashjs error:\n${err?.stack || err}` : 'Internal Server Error';
102
- send(res, 500, 'text/plain; charset=utf-8', msg, secHeaders);
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
- const rendered = await renderComponent(mod, props);
170
- res.write(`<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${id}.js"></script>`);
171
- res.end(tail);
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
- if (ae.includes('br') && existsSync(file + '.br')) {
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
- const buf = await fs.readFile(file);
206
- res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'cache-control': 'public, max-age=3600' });
207
- res.end(buf);
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
- return false;
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) {