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 +12 -8
- package/package.json +10 -2
- package/src/server/server.mjs +109 -16
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# glashjs
|
|
2
2
|
|
|
3
|
-
The glashdb-native web framework — a
|
|
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
|
|
28
|
-
|
|
29
|
-
- **Text / SVG / JS / CSS / HTML** → **Brotli + Gzip** (`zlib`, built in). Real 4–8× on text/SVG. The browser decompresses transparently via `Content-Encoding` —
|
|
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 —
|
|
46
|
-
|
|
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
|
-
- [
|
|
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
|
|
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.
|
|
4
|
-
"description": "glashjs — a
|
|
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
|
}
|
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) {
|