glashjs 0.7.2 → 0.8.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
@@ -174,7 +176,9 @@ animatedFavicon: true, // bundled animated glash mark (d
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
+ - [ ] State-preserving fast-refresh, 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.8.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": {
@@ -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) {