glashjs 0.7.1 → 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
@@ -1,6 +1,6 @@
1
1
  # glashjs
2
2
 
3
- The glashdb-native web framework — a **Next.js alternative** with the same file-based routing, SSR, API routes, layouts, and JSX component model you know from Next, rebuilt from scratch on **Preact + esbuild + Node** (zero Next dependency) and made **fast, offline-capable, and hard-to-hack by default**. It ships real features instead of promises.
3
+ The glashdb-native web framework — **a Next.js alternative** with the file-based routing, SSR, API routes, layouts, and JSX component model you know from Next, made **fast, offline-capable, and secure by default**. It ships real features instead of promises.
4
4
 
5
5
  > **Status:** `0.6.0` — the full framework is here: file-based routing, server-side rendering, API routes, JSX components with client hydration, nested layouts, streaming SSR, a dev/prod server, plus the asset optimizer, offline service worker, animated favicon, and secure-by-default headers. Core installs with zero mandatory dependencies.
6
6
 
@@ -24,9 +24,9 @@ security strict CSP + 11 headers
24
24
 
25
25
  ## The three pillars
26
26
 
27
- ### 1. Asset optimizer — the honest version of "10–20× compression"
28
- JPG and MP4 are *already* compressed; you can't losslessly shrink them 10–20× and restore them. So glashjs does what actually wins:
29
- - **Text / SVG / JS / CSS / HTML** → **Brotli + Gzip** (`zlib`, built in). Real 4–8× on text/SVG. The browser decompresses transparently via `Content-Encoding` — *that's* "compress on build, decompress when live," done correctly.
27
+ ### 1. Asset optimizer — the smallest payload a browser can decode
28
+ At build time glashjs re-encodes every asset to the leanest format the client supports, then serves the best variant per request — no config, no runtime image server, originals never touched. How each asset type is handled:
29
+ - **Text / SVG / JS / CSS / HTML** → **Brotli + Gzip** (`zlib`, built in). Real 4–8× on text/SVG. The browser decompresses transparently via `Content-Encoding` — compress on build, decompress in the browser.
30
30
  - **jpg / png / webp** → **AVIF + WebP** variants (needs optional `sharp`). Typically 3–10× vs unoptimized originals.
31
31
  - **mp4 / mov / webm** → **AV1** + poster frame (needs optional `ffmpeg`).
32
32
  - Emits `glash-assets.manifest.json` so the glashdb edge (or any server) serves the best variant per client. Originals are never mutated.
@@ -42,8 +42,8 @@ Generates a **Service Worker** (`glash-sw.js`) + PWA manifest that precache the
42
42
  - **HTML** → stale-while-revalidate (instant, self-healing)
43
43
  - **`/api` `/rest` `/auth` `/live` `/stream`** → **network-first**, so offline mode degrades *exactly* at live/updated data and streaming — the site keeps working, just without fresh data. (Configurable via `dataPrefixes`.)
44
44
 
45
- ### 3. Security — "hard to hack," honestly (not "unhackable")
46
- Nothing is unhackable. glashjs ships strong, opinionated defaults so you're secure unless you loosen them:
45
+ ### 3. Security — secure by default
46
+ glashjs ships strong, opinionated defaults so you're secure unless you loosen them:
47
47
  - **Strict CSP** with no `'unsafe-inline'` scripts (XSS-via-injection blocked by default)
48
48
  - HSTS, `X-Content-Type-Options`, `X-Frame-Options: DENY`, COOP/COEP/CORP isolation, tight `Permissions-Policy` & `Referrer-Policy`
49
49
  - **Subresource Integrity** helper (`sri()`) for build assets
@@ -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,10 +176,12 @@ 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
181
185
 
182
186
  ## Design stance
183
- glashjs is a **Next.js alternative** — it keeps the conventions you know from Next (file-based routing, SSR, layouts, the component model) but is built from scratch on **Preact + esbuild + Node**, with **zero Next.js dependency**. The value is in the **defaults**: every glashjs site is optimized, offline-capable, and hardened out of the box.
187
+ glashjs is **a Next.js alternative** — it keeps the conventions you know from Next (file-based routing, SSR, layouts, the component model) and composes proven primitives rather than reinventing them. The value is in the **defaults**: every glashjs site is optimized, offline-capable, and secure out of the box.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.7.1",
4
- "description": "glashjs — a Next.js alternative built on Preact + esbuild + Node: 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.",
3
+ "version": "0.8.0",
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": {
7
7
  "glash": "bin/glash.mjs"
@@ -64,5 +64,13 @@
64
64
  "esbuild": "^0.28.0",
65
65
  "preact": "^10.29.2",
66
66
  "preact-render-to-string": "^6.7.0"
67
+ },
68
+ "repository": {
69
+ "type": "git",
70
+ "url": "git+https://github.com/theChrisJohn/glashjs.git"
71
+ },
72
+ "homepage": "https://github.com/theChrisJohn/glashjs#readme",
73
+ "bugs": {
74
+ "url": "https://github.com/theChrisJohn/glashjs/issues"
67
75
  }
68
76
  }
@@ -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) {