glashjs 0.0.3 → 0.2.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
@@ -49,6 +49,51 @@ Nothing is unhackable. glashjs ships strong, opinionated defaults so you're secu
49
49
  - **Subresource Integrity** helper (`sri()`) for build assets
50
50
  - Emitted to `glash-headers.json` for the edge, plus a `glashSecurity()` Express middleware
51
51
 
52
+ ## Routing, SSR & API (new in 0.1)
53
+ glashjs now has a real server runtime — **file-based routing**, **server-side rendering**, and **API routes** — on Node built-ins, zero deps.
54
+
55
+ ```
56
+ routes/
57
+ index.mjs -> / (SSR page)
58
+ about.mjs -> /about
59
+ blog/[slug].mjs -> /blog/:slug (dynamic segment -> ctx.params.slug)
60
+ docs/[...path].mjs -> /docs/* (catch-all)
61
+ api/hello.mjs -> /api/hello (API: export GET/POST/…)
62
+ ```
63
+
64
+ ```js
65
+ // routes/index.mjs — a server-rendered page (XSS-safe `html` template)
66
+ import { html } from 'glashjs';
67
+ export default (ctx) => ({ title: 'Home', body: html`<h1>Hello ${ctx.query.name}</h1>` });
68
+
69
+ // routes/api/hello.mjs — an API route
70
+ import { json } from 'glashjs';
71
+ export const GET = (ctx) => ({ ok: true, name: ctx.query.name });
72
+ export const POST = (ctx) => json({ created: ctx.body }, { status: 201 });
73
+ ```
74
+
75
+ ```bash
76
+ glash dev # dev server: routing + SSR + API, live route reload
77
+ glash serve # production server over routes/ + built assets (Brotli-negotiated)
78
+ ```
79
+
80
+ Every response carries the secure-by-default headers; static files are served from the build with Brotli negotiation.
81
+
82
+ ### JSX components + client hydration (new in 0.2)
83
+ Author pages as **JSX components**, server-render them, and **hydrate** in the browser so hooks work — real interactivity, the Next-style model. Built on **Preact** (React-compatible) + **esbuild**, added as *optional peers* (`npm i preact preact-render-to-string esbuild`); glashjs core stays zero-dep.
84
+
85
+ ```jsx
86
+ // routes/counter.jsx — SSR + hydrated, the button is interactive
87
+ import { useState } from 'preact/hooks';
88
+ export function getServerData(ctx) { return { start: Number(ctx.query.start || 0) }; } // server props
89
+ export default function Counter({ start = 0 }) {
90
+ const [n, setN] = useState(start);
91
+ return <button onClick={() => setN(n + 1)}>count is {n}</button>;
92
+ }
93
+ ```
94
+
95
+ 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'`). **Honest scope:** uses Preact (React-compatible via `preact/compat`), not React itself; nested layouts, HMR, and streaming are still ahead.
96
+
52
97
  ## Usage
53
98
 
54
99
  ```js
@@ -92,10 +137,15 @@ animatedFavicon: true, // bundled animated glash mark (d
92
137
  - [x] Asset optimizer (Brotli/Gzip real; AVIF/WebP/AV1 via optional sharp/ffmpeg)
93
138
  - [x] Offline Service Worker + PWA manifest
94
139
  - [x] Secure-by-default headers + CSP + SRI
140
+ - [x] File-based routing (pages + `api/`, dynamic `[param]` & catch-all `[...path]`)
141
+ - [x] Server-side rendering (XSS-safe `html` templates) + full-document runtime
142
+ - [x] API routes (per-method handlers, JSON body parsing, typed `json()` responses)
143
+ - [x] Dev/prod server with live route reload + Brotli-negotiated static serving
144
+ - [x] JSX components + client-side hydration (Preact + esbuild) — CSP-safe with nonces
145
+ - [x] Client-JS bundling (esbuild) per route
146
+ - [ ] Nested layouts, streaming SSR, HMR (fast refresh)
95
147
  - [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
96
- - [ ] Dev server + HMR (on Vite/esbuild) and file-based routing
97
- - [ ] SSR / streaming + edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
98
- - [ ] Signed, immutable asset URLs + automatic SRI injection into HTML
148
+ - [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
99
149
  - [ ] `glash deploy` → glashdb hosting in one command
100
150
 
101
151
  ## Design stance
package/bin/glash.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { build } from '../src/build.mjs';
5
5
  import { optimizeAssets } from '../src/assets/optimize.mjs';
6
+ import { createGlashServer } from '../src/server/server.mjs';
6
7
 
7
8
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
8
9
  const [, , cmd, ...rest] = process.argv;
@@ -12,12 +13,32 @@ function arg(name, fallback) {
12
13
  return i >= 0 && rest[i + 1] ? rest[i + 1] : fallback;
13
14
  }
14
15
 
16
+ async function serve(dev) {
17
+ const root = arg('--root', process.cwd());
18
+ const { listen, cfg, routes } = await createGlashServer({ root, dev });
19
+ const port = Number(arg('--port', cfg.port || 3000));
20
+ const { host } = await listen(port);
21
+ const pages = routes.filter((r) => !r.isApi).length;
22
+ const apis = routes.filter((r) => r.isApi).length;
23
+ console.log(`\nglashjs ${dev ? 'dev' : 'serve'} — "${cfg.name}"`);
24
+ console.log(` ${pages} page route(s), ${apis} api route(s)`);
25
+ routes.forEach((r) => console.log(` ${r.isApi ? 'api ' : 'page'} ${r.pattern}`));
26
+ console.log(`\n ▶ http://localhost:${port}${dev ? ' (live route reload)' : ''}\n`);
27
+ }
28
+
15
29
  async function main() {
16
30
  switch (cmd) {
17
31
  case 'build': {
18
32
  await build({ root: arg('--root', process.cwd()) });
19
33
  break;
20
34
  }
35
+ case 'dev':
36
+ await serve(true);
37
+ break;
38
+ case 'serve':
39
+ case 'start':
40
+ await serve(false);
41
+ break;
21
42
  case 'optimize': {
22
43
  const dir = rest[0] && !rest[0].startsWith('--') ? rest[0] : 'public';
23
44
  console.log(`glashjs optimize — ${dir}\n`);
@@ -35,6 +56,8 @@ async function main() {
35
56
  console.log(`glashjs — fast, offline-capable, hard-to-hack sites
36
57
 
37
58
  Usage:
59
+ glash dev [--port 3000] Run the dev server (file-based routing, SSR, API, live reload)
60
+ glash serve [--port 3000] Run the production server over routes/ + built assets
38
61
  glash build [--root <dir>] Optimize assets, generate offline SW + PWA + security manifests
39
62
  glash optimize [<dir>] Just run the asset optimizer over a directory
40
63
  glash version Print version
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.0.3",
4
- "description": "glashjs — a build pipeline + runtime conventions for fast, offline-capable, hard-to-hack sites. Built on proven primitives, with a best-in-class asset optimizer, PWA offline layer, and secure-by-default headers.",
3
+ "version": "0.2.0",
4
+ "description": "glashjs — a web framework with file-based routing, SSR, API routes, a best-in-class asset optimizer, offline PWA layer, animated favicon, and secure-by-default headers. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "glash": "bin/glash.mjs"
@@ -19,13 +19,24 @@
19
19
  "engines": {
20
20
  "node": ">=20"
21
21
  },
22
- "dependencies": {},
23
22
  "peerDependencies": {
23
+ "esbuild": ">=0.20.0",
24
+ "preact": "^10.25.0",
25
+ "preact-render-to-string": "^6.5.0",
24
26
  "sharp": "^0.34.5"
25
27
  },
26
28
  "peerDependenciesMeta": {
27
29
  "sharp": {
28
30
  "optional": true
31
+ },
32
+ "esbuild": {
33
+ "optional": true
34
+ },
35
+ "preact": {
36
+ "optional": true
37
+ },
38
+ "preact-render-to-string": {
39
+ "optional": true
29
40
  }
30
41
  },
31
42
  "keywords": [
@@ -37,7 +48,17 @@
37
48
  "asset-optimization",
38
49
  "avif",
39
50
  "brotli",
40
- "security"
51
+ "security",
52
+ "routing",
53
+ "ssr",
54
+ "api",
55
+ "server",
56
+ "web-framework"
41
57
  ],
42
- "license": "MIT"
58
+ "license": "MIT",
59
+ "devDependencies": {
60
+ "esbuild": "^0.28.0",
61
+ "preact": "^10.29.2",
62
+ "preact-render-to-string": "^6.7.0"
63
+ }
43
64
  }
package/src/config.mjs CHANGED
@@ -17,6 +17,9 @@ export const DEFAULT_CONFIG = {
17
17
  // false disables it. Emits `glash-favicon.mjs` — call startGlashFavicon() once.
18
18
  animatedFavicon: true,
19
19
  publicDir: 'public',
20
+ // File-based routes (pages + api/) served by `glash dev` / `glash serve`.
21
+ routesDir: 'routes',
22
+ port: 3000,
20
23
  outDir: '.glash/out',
21
24
  themeColor: '#0b0d12',
22
25
  offline: true,
package/src/index.mjs CHANGED
@@ -5,3 +5,6 @@ export { optimizeAssets } from './assets/optimize.mjs';
5
5
  export { generateAnimatedFavicon } from './assets/animated-favicon.mjs';
6
6
  export { generateServiceWorker } from './offline/generate-sw.mjs';
7
7
  export { securityHeaders, buildCsp, sri, glashSecurity } from './security/headers.mjs';
8
+ export { createGlashServer, json } from './server/server.mjs';
9
+ export { discoverRoutes, matchRoute } from './server/router.mjs';
10
+ export { html, raw, escapeHtml, renderDocument } from './server/html.mjs';
@@ -0,0 +1,70 @@
1
+ // glashjs SSR HTML layer
2
+ // ---------------------------------------------------------------------------
3
+ // A secure-by-default templating helper + full-document renderer. The `html`
4
+ // tagged template ESCAPES every interpolation (so user data can't inject
5
+ // markup — XSS-safe by default, matching the security pillar). Use `raw()` to
6
+ // deliberately embed already-rendered HTML (e.g. a nested component).
7
+ export function escapeHtml(value) {
8
+ return String(value).replace(/[&<>"']/g, (c) => ({
9
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
10
+ }[c]));
11
+ }
12
+
13
+ export function raw(value) {
14
+ return { __raw: String(value) };
15
+ }
16
+
17
+ function interpolate(v) {
18
+ if (v == null || v === false) return '';
19
+ if (Array.isArray(v)) return v.map(interpolate).join('');
20
+ if (typeof v === 'object' && '__raw' in v) return v.__raw;
21
+ return escapeHtml(v);
22
+ }
23
+
24
+ export function html(strings, ...values) {
25
+ let out = '';
26
+ strings.forEach((str, i) => { out += str + (i < values.length ? interpolate(values[i]) : ''); });
27
+ return raw(out);
28
+ }
29
+
30
+ /**
31
+ * Wrap a page body into a full HTML document with the glashjs runtime wired in:
32
+ * favicon, animated-favicon runtime, and offline service-worker registration.
33
+ * The runtime imports are resilient (try/catch) so pages still render in `dev`
34
+ * before a `glash build` has produced those files.
35
+ */
36
+ export function renderDocument({
37
+ title = 'glashjs',
38
+ head = '',
39
+ body = '',
40
+ lang = 'en',
41
+ favicon = '/favicon.svg',
42
+ offline = true,
43
+ animatedFavicon = true,
44
+ nonce = '',
45
+ } = {}) {
46
+ const headHtml = typeof head === 'object' && head?.__raw ? head.__raw : String(head ?? '');
47
+ const bodyHtml = typeof body === 'object' && body?.__raw ? body.__raw : String(body ?? '');
48
+ const n = nonce ? ` nonce="${escapeHtml(nonce)}"` : '';
49
+ const fav = animatedFavicon
50
+ ? `<script type="module"${n}>try{const m=await import("/glash-favicon.mjs");m.startGlashFavicon&&m.startGlashFavicon();}catch{}</script>`
51
+ : '';
52
+ const off = offline
53
+ ? `<script type="module"${n}>try{const m=await import("/glash-offline.mjs");m.registerGlashOffline&&m.registerGlashOffline();}catch{}</script>`
54
+ : '';
55
+ return `<!doctype html>
56
+ <html lang="${escapeHtml(lang)}">
57
+ <head>
58
+ <meta charset="utf-8">
59
+ <meta name="viewport" content="width=device-width, initial-scale=1">
60
+ <title>${escapeHtml(title)}</title>
61
+ <link rel="icon" href="${escapeHtml(favicon)}" type="image/svg+xml">
62
+ <link rel="manifest" href="/manifest.webmanifest">
63
+ ${headHtml}
64
+ </head>
65
+ <body>
66
+ ${bodyHtml}
67
+ ${fav}${off}
68
+ </body>
69
+ </html>`;
70
+ }
@@ -0,0 +1,113 @@
1
+ // glashjs JSX + hydration engine
2
+ // ---------------------------------------------------------------------------
3
+ // This is the layer that makes glashjs a real Next.js alternative: author
4
+ // pages as JSX components, render them on the server, and HYDRATE them in the
5
+ // browser so hooks (useState/useEffect) work — true interactivity.
6
+ //
7
+ // Built on proven primitives, added as OPTIONAL peers so glashjs core stays
8
+ // zero-dependency:
9
+ // - esbuild -> compiles JSX/TSX (server module + client bundle)
10
+ // - preact -> the component runtime (React-compatible)
11
+ // - preact-render-to-string -> server-side rendering
12
+ //
13
+ // A `.jsx`/`.tsx` route exports a default component and (optionally) an async
14
+ // `getServerData(ctx)` that returns props for SSR + hydration. Plain `.mjs`
15
+ // HTML-render routes keep working with zero deps.
16
+ import { promises as fs } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { createHash } from 'node:crypto';
19
+ import { pathToFileURL } from 'node:url';
20
+
21
+ const MISSING = 'JSX/TSX routes need the optional peers: npm i esbuild preact preact-render-to-string';
22
+
23
+ let _esbuild;
24
+ async function esbuild() {
25
+ if (_esbuild !== undefined) return _esbuild;
26
+ try { _esbuild = await import('esbuild'); } catch { _esbuild = null; }
27
+ return _esbuild;
28
+ }
29
+
30
+ let _h, _renderToString;
31
+ async function preactRuntime() {
32
+ if (_h) return { h: _h, renderToString: _renderToString };
33
+ const p = await import('preact').catch(() => null);
34
+ const r = await import('preact-render-to-string').catch(() => null);
35
+ if (!p || !r) return null;
36
+ _h = p.h;
37
+ _renderToString = r.renderToString || r.default;
38
+ return { h: _h, renderToString: _renderToString };
39
+ }
40
+
41
+ export function isComponentRoute(file) {
42
+ return /\.(jsx|tsx)$/.test(file);
43
+ }
44
+
45
+ export function routeId(file) {
46
+ return createHash('sha1').update(file).digest('hex').slice(0, 10);
47
+ }
48
+
49
+ const serverCache = new Map();
50
+ const clientCache = new Map();
51
+
52
+ /** Compile a JSX/TSX route for the server and import it (default component + getServerData). */
53
+ export async function loadComponentRoute(file, root, dev) {
54
+ const eb = await esbuild();
55
+ if (!eb) throw new Error(MISSING);
56
+ if (!dev && serverCache.has(file)) return serverCache.get(file);
57
+ const out = path.join(root, '.glash', 'server', routeId(file) + '.mjs');
58
+ await fs.mkdir(path.dirname(out), { recursive: true });
59
+ await eb.build({
60
+ entryPoints: [file],
61
+ bundle: true,
62
+ platform: 'node',
63
+ format: 'esm',
64
+ jsx: 'automatic',
65
+ jsxImportSource: 'preact',
66
+ // Keep preact external so the route shares the framework's preact instance
67
+ // (vnodes from the route and renderToString must come from the same preact).
68
+ external: ['preact', 'preact/*', 'preact-render-to-string'],
69
+ outfile: out,
70
+ logLevel: 'silent',
71
+ });
72
+ const mod = await import(pathToFileURL(out).href + (dev ? `?t=${Date.now()}` : ''));
73
+ if (!dev) serverCache.set(file, mod);
74
+ return mod;
75
+ }
76
+
77
+ /** Build the browser hydration bundle for a route (preact bundled in). */
78
+ export async function clientBundle(file, dev) {
79
+ const eb = await esbuild();
80
+ if (!eb) throw new Error(MISSING);
81
+ if (!dev && clientCache.has(file)) return clientCache.get(file);
82
+ // Props arrive in a non-executed <script type="application/json"> block, so
83
+ // hydration works under glashjs's strict CSP (no inline executable scripts).
84
+ const entry = `import { hydrate, h } from 'preact';
85
+ import Page from ${JSON.stringify(file)};
86
+ const el = document.getElementById('glash-root');
87
+ const pe = document.getElementById('glash-props');
88
+ let props = {};
89
+ try { props = pe ? JSON.parse(pe.textContent) : {}; } catch {}
90
+ if (el) hydrate(h(Page, props), el);`;
91
+ const res = await eb.build({
92
+ stdin: { contents: entry, resolveDir: path.dirname(file), loader: 'jsx', sourcefile: 'glash-client-entry.jsx' },
93
+ bundle: true,
94
+ platform: 'browser',
95
+ format: 'esm',
96
+ minify: !dev,
97
+ jsx: 'automatic',
98
+ jsxImportSource: 'preact',
99
+ write: false,
100
+ logLevel: 'silent',
101
+ });
102
+ const js = res.outputFiles[0].text;
103
+ if (!dev) clientCache.set(file, js);
104
+ return js;
105
+ }
106
+
107
+ /** Server-render a route component to an HTML string. */
108
+ export async function renderComponent(mod, props) {
109
+ const rt = await preactRuntime();
110
+ if (!rt) throw new Error(MISSING);
111
+ if (typeof mod.default !== 'function') throw new Error('JSX route must default-export a component');
112
+ return rt.renderToString(rt.h(mod.default, props));
113
+ }
@@ -0,0 +1,76 @@
1
+ // glashjs file-based router
2
+ // ---------------------------------------------------------------------------
3
+ // Maps files under the routes/ dir to URL patterns, Next-style:
4
+ // routes/index.mjs -> /
5
+ // routes/about.mjs -> /about
6
+ // routes/blog/[slug].mjs -> /blog/:slug (dynamic segment)
7
+ // routes/docs/[...path].mjs -> /docs/*path (catch-all)
8
+ // routes/api/hello.mjs -> /api/hello (API route — exports GET/POST/…)
9
+ // Anything under routes/api/ is an API route; everything else is a page (SSR).
10
+ import { promises as fs } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ export async function discoverRoutes(routesDir) {
14
+ const root = path.resolve(routesDir);
15
+ const files = [];
16
+ await walk(root, root, files);
17
+ const routes = files
18
+ .filter((f) => /\.(mjs|js|jsx|tsx)$/.test(f.rel))
19
+ .map((f) => toRoute(f.rel, f.file));
20
+ // Most specific first: static segments beat params beat catch-all.
21
+ routes.sort((a, b) => b.score - a.score);
22
+ return routes;
23
+ }
24
+
25
+ async function walk(root, dir, out) {
26
+ let entries;
27
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
28
+ for (const e of entries) {
29
+ const full = path.join(dir, e.name);
30
+ if (e.isDirectory()) await walk(root, full, out);
31
+ else if (e.isFile()) out.push({ file: full, rel: path.relative(root, full).split(path.sep).join('/') });
32
+ }
33
+ }
34
+
35
+ function toRoute(rel, file) {
36
+ const clean = rel.replace(/\.(mjs|js|jsx|tsx)$/, '');
37
+ const isApi = clean === 'api' || clean.startsWith('api/');
38
+ const segs = [];
39
+ for (const part of clean.split('/').filter(Boolean)) {
40
+ if (part === 'index') continue;
41
+ if (part.startsWith('[...') && part.endsWith(']')) segs.push({ type: 'catchall', name: part.slice(4, -1) });
42
+ else if (part.startsWith('[') && part.endsWith(']')) segs.push({ type: 'param', name: part.slice(1, -1) });
43
+ else segs.push({ type: 'static', value: part });
44
+ }
45
+ const pattern = '/' + segs.map((s) => (s.type === 'static' ? s.value : s.type === 'param' ? `:${s.name}` : `*${s.name}`)).join('/');
46
+ const score = segs.reduce((acc, s) => acc + (s.type === 'static' ? 3 : s.type === 'param' ? 2 : 1), 0) * 10 + segs.length;
47
+ return { file, isApi, segs, pattern: pattern === '/' ? '/' : pattern, score };
48
+ }
49
+
50
+ export function matchRoute(routes, pathname) {
51
+ const reqSegs = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
52
+ for (const route of routes) {
53
+ const params = matchSegs(route.segs, reqSegs);
54
+ if (params) return { route, params };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function matchSegs(segs, reqSegs) {
60
+ const params = {};
61
+ let i = 0;
62
+ for (const seg of segs) {
63
+ if (seg.type === 'catchall') {
64
+ params[seg.name] = reqSegs.slice(i).map(decodeURIComponent).join('/');
65
+ return params;
66
+ }
67
+ if (i >= reqSegs.length) return null;
68
+ if (seg.type === 'static') {
69
+ if (reqSegs[i] !== seg.value) return null;
70
+ } else {
71
+ params[seg.name] = decodeURIComponent(reqSegs[i]);
72
+ }
73
+ i++;
74
+ }
75
+ return i === reqSegs.length ? params : null;
76
+ }
@@ -0,0 +1,197 @@
1
+ // glashjs server — routing, SSR, and API in one Node http server.
2
+ // ---------------------------------------------------------------------------
3
+ // `dev: true` -> re-discovers routes and re-imports modules each request, so
4
+ // edits show up without a restart (live reload of route code).
5
+ // `dev: false` -> caches routes; serves the built `outDir` (optimized assets,
6
+ // service worker, favicons) with Brotli negotiation.
7
+ // Every response carries the secure-by-default headers.
8
+ import http from 'node:http';
9
+ import { promises as fs, existsSync, statSync } from 'node:fs';
10
+ import { randomBytes } from 'node:crypto';
11
+ import path from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+ import { discoverRoutes, matchRoute } from './router.mjs';
14
+ import { renderDocument } from './html.mjs';
15
+ import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId } from './jsx.mjs';
16
+ import { securityHeaders } from '../security/headers.mjs';
17
+ import { loadConfig } from '../config.mjs';
18
+
19
+ const MIME = {
20
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript',
21
+ '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml',
22
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
23
+ '.avif': 'image/avif', '.webmanifest': 'application/manifest+json', '.ico': 'image/x-icon',
24
+ '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8',
25
+ };
26
+ const mime = (file) => MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
27
+
28
+ /** Build the glashjs server. Returns { server, listen, cfg }. */
29
+ export async function createGlashServer({ root = process.cwd(), dev = false } = {}) {
30
+ const cfg = await loadConfig(root);
31
+ const routesDir = path.resolve(root, cfg.routesDir || 'routes');
32
+ const outDir = path.resolve(root, cfg.outDir);
33
+ const secHeaders = securityHeaders(cfg.security);
34
+ let routes = await discoverRoutes(routesDir);
35
+
36
+ const importRoute = (file) =>
37
+ import(pathToFileURL(file).href + (dev ? `?t=${Date.now()}` : ''));
38
+
39
+ const server = http.createServer(async (req, res) => {
40
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
41
+ const pathname = safeDecode(url.pathname);
42
+ try {
43
+ if (dev) routes = await discoverRoutes(routesDir);
44
+ // Client hydration bundles: /_glash/<routeId>.js
45
+ if (pathname.startsWith('/_glash/')) {
46
+ const id = pathname.slice('/_glash/'.length).replace(/\.js$/, '');
47
+ const comp = routes.find((r) => isComponentRoute(r.file) && routeId(r.file) === id);
48
+ if (!comp) return send(res, 404, 'text/plain', 'not found', secHeaders);
49
+ const js = await clientBundle(comp.file, dev);
50
+ return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
51
+ }
52
+ if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
53
+ const match = matchRoute(routes, pathname);
54
+ if (!match) return send(res, 404, 'text/plain; charset=utf-8', 'Not found', secHeaders);
55
+ const ctx = makeCtx(req, url, match.params);
56
+ if (match.route.isApi) {
57
+ const mod = await importRoute(match.route.file);
58
+ return await handleApi(res, mod, req, ctx, secHeaders);
59
+ }
60
+ if (isComponentRoute(match.route.file)) {
61
+ return await handleComponentPage(res, match.route, ctx, cfg, secHeaders, root, dev);
62
+ }
63
+ const mod = await importRoute(match.route.file);
64
+ return await handlePage(res, mod, ctx, cfg, secHeaders);
65
+ } catch (err) {
66
+ const msg = dev ? `glashjs error:\n${err?.stack || err}` : 'Internal Server Error';
67
+ send(res, 500, 'text/plain; charset=utf-8', msg, secHeaders);
68
+ }
69
+ });
70
+
71
+ const listen = (port = cfg.port || 3000, host = '0.0.0.0') =>
72
+ new Promise((resolve) => server.listen(port, host, () => resolve({ port, host })));
73
+
74
+ return { server, listen, cfg, routes, routesDir, outDir };
75
+ }
76
+
77
+ async function handleApi(res, mod, req, ctx, secHeaders) {
78
+ const method = req.method.toUpperCase();
79
+ const handler = mod[method] || (method === 'GET' && mod.default) || mod.handler;
80
+ if (typeof handler !== 'function') {
81
+ return send(res, 405, 'application/json', JSON.stringify({ error: 'method not allowed' }), secHeaders);
82
+ }
83
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) ctx.body = await readJson(req);
84
+ const result = await handler(ctx);
85
+ if (result && result.__response) {
86
+ return send(res, result.status || 200, result.contentType || 'application/json',
87
+ typeof result.body === 'string' ? result.body : JSON.stringify(result.body), { ...secHeaders, ...(result.headers || {}) });
88
+ }
89
+ send(res, 200, 'application/json', JSON.stringify(result ?? null), secHeaders);
90
+ }
91
+
92
+ async function handlePage(res, mod, ctx, cfg, secHeaders) {
93
+ const render = mod.default;
94
+ if (typeof render !== 'function') return send(res, 500, 'text/plain', 'route has no default export', secHeaders);
95
+ const out = await render(ctx);
96
+ const page = typeof out === 'string' || (out && out.__raw) ? { body: out } : (out || {});
97
+ const nonce = randomBytes(16).toString('base64');
98
+ const docHtml = renderDocument({
99
+ title: page.title || mod.title || cfg.name,
100
+ head: page.head || '',
101
+ body: page.body ?? '',
102
+ offline: cfg.offline,
103
+ animatedFavicon: !!cfg.animatedFavicon,
104
+ nonce,
105
+ });
106
+ send(res, page.status || 200, 'text/html; charset=utf-8', docHtml, { ...pageHeaders(cfg, secHeaders, nonce), ...(page.headers || {}) });
107
+ }
108
+
109
+ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, dev) {
110
+ const mod = await loadComponentRoute(route.file, root, dev);
111
+ const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
112
+ const rendered = await renderComponent(mod, props);
113
+ const id = routeId(route.file);
114
+ const nonce = randomBytes(16).toString('base64');
115
+ // Props in a non-executed JSON block (CSP-safe); hydration bundle is an
116
+ // external 'self' module — both pass the strict CSP without 'unsafe-inline'.
117
+ const head = `<script type="application/json" id="glash-props">${safeJson(props)}</script>`;
118
+ const body = `<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${id}.js"></script>`;
119
+ const docHtml = renderDocument({
120
+ title: mod.title || cfg.name,
121
+ head,
122
+ body,
123
+ offline: cfg.offline,
124
+ animatedFavicon: !!cfg.animatedFavicon,
125
+ nonce,
126
+ });
127
+ send(res, 200, 'text/html; charset=utf-8', docHtml, pageHeaders(cfg, secHeaders, nonce));
128
+ }
129
+
130
+ // Per-request page headers: a fresh CSP carrying this request's script nonce, so
131
+ // the framework's own inline <script>s run while injected scripts stay blocked.
132
+ function pageHeaders(cfg, secHeaders, nonce) {
133
+ const csp = securityHeaders({ ...(cfg.security || {}), csp: { ...((cfg.security || {}).csp || {}), nonce } })['Content-Security-Policy'];
134
+ return { ...secHeaders, 'Content-Security-Policy': csp };
135
+ }
136
+
137
+ // Serialize props for inline injection without breaking out of the <script>.
138
+ function safeJson(value) {
139
+ return JSON.stringify(value ?? {}).replace(/</g, '\\u003c').replace(/-->/g, '--\\u003e');
140
+ }
141
+
142
+ async function serveStatic(res, outDir, pathname, req, secHeaders) {
143
+ if (pathname === '/') return false; // let the index page route render
144
+ const rel = pathname.replace(/^\/+/, '');
145
+ const file = path.join(outDir, rel);
146
+ if (!file.startsWith(outDir + path.sep)) return false; // path traversal guard
147
+ const ae = String(req.headers['accept-encoding'] || '');
148
+ if (ae.includes('br') && existsSync(file + '.br')) {
149
+ const buf = await fs.readFile(file + '.br');
150
+ res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'content-encoding': 'br', 'cache-control': 'public, max-age=31536000, immutable' });
151
+ res.end(buf);
152
+ return true;
153
+ }
154
+ if (existsSync(file) && statSync(file).isFile()) {
155
+ const buf = await fs.readFile(file);
156
+ res.writeHead(200, { ...secHeaders, 'content-type': mime(file), 'cache-control': 'public, max-age=3600' });
157
+ res.end(buf);
158
+ return true;
159
+ }
160
+ return false;
161
+ }
162
+
163
+ function makeCtx(req, url, params) {
164
+ return {
165
+ req,
166
+ method: req.method,
167
+ url,
168
+ path: url.pathname,
169
+ params,
170
+ query: Object.fromEntries(url.searchParams),
171
+ headers: req.headers,
172
+ body: undefined,
173
+ };
174
+ }
175
+
176
+ function readJson(req) {
177
+ return new Promise((resolve) => {
178
+ let data = '';
179
+ req.on('data', (c) => { data += c; if (data.length > 2_000_000) req.destroy(); });
180
+ req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); } });
181
+ req.on('error', () => resolve({}));
182
+ });
183
+ }
184
+
185
+ function send(res, status, contentType, body, headers = {}) {
186
+ res.writeHead(status, { ...headers, 'content-type': contentType });
187
+ res.end(body);
188
+ }
189
+
190
+ function safeDecode(p) {
191
+ try { return decodeURIComponent(p); } catch { return p; }
192
+ }
193
+
194
+ /** API helper: return a typed Response from a handler. */
195
+ export function json(body, { status = 200, headers } = {}) {
196
+ return { __response: true, status, contentType: 'application/json', body, headers };
197
+ }