glashjs 0.2.0 → 0.3.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
@@ -92,7 +92,9 @@ export default function Counter({ start = 0 }) {
92
92
  }
93
93
  ```
94
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.
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'`).
96
+
97
+ **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.
96
98
 
97
99
  ## Usage
98
100
 
@@ -143,7 +145,10 @@ animatedFavicon: true, // bundled animated glash mark (d
143
145
  - [x] Dev/prod server with live route reload + Brotli-negotiated static serving
144
146
  - [x] JSX components + client-side hydration (Preact + esbuild) — CSP-safe with nonces
145
147
  - [x] Client-JS bundling (esbuild) per route
146
- - [ ] Nested layouts, streaming SSR, HMR (fast refresh)
148
+ - [x] Nested layouts (`_layout.jsx` composing root→leaf, server + hydration)
149
+ - [x] Streaming SSR (shell flushed before the component renders)
150
+ - [x] Dev live-reload over SSE (auto-refresh on save)
151
+ - [ ] State-preserving fast-refresh (HMR), Suspense streaming, `<Image>`/`<Video>` components
147
152
  - [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
148
153
  - [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
149
154
  - [ ] `glash deploy` → glashdb hosting in one command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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": {
@@ -33,25 +33,8 @@ export function html(strings, ...values) {
33
33
  * The runtime imports are resilient (try/catch) so pages still render in `dev`
34
34
  * before a `glash build` has produced those files.
35
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
- } = {}) {
36
+ function shellOpen({ title = 'glashjs', head = '', lang = 'en', favicon = '/favicon.svg' }) {
46
37
  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
38
  return `<!doctype html>
56
39
  <html lang="${escapeHtml(lang)}">
57
40
  <head>
@@ -63,8 +46,31 @@ export function renderDocument({
63
46
  ${headHtml}
64
47
  </head>
65
48
  <body>
66
- ${bodyHtml}
67
- ${fav}${off}
68
- </body>
69
- </html>`;
49
+ `;
50
+ }
51
+
52
+ function shellTail({ offline = true, animatedFavicon = true, dev = false, nonce = '' }) {
53
+ const n = nonce ? ` nonce="${escapeHtml(nonce)}"` : '';
54
+ const fav = animatedFavicon
55
+ ? `<script type="module"${n}>try{const m=await import("/glash-favicon.mjs");m.startGlashFavicon&&m.startGlashFavicon();}catch{}</script>`
56
+ : '';
57
+ const off = offline
58
+ ? `<script type="module"${n}>try{const m=await import("/glash-offline.mjs");m.registerGlashOffline&&m.registerGlashOffline();}catch{}</script>`
59
+ : '';
60
+ // Dev live-reload: a nonce'd inline script (CSP-safe) that reloads on change.
61
+ const hmr = dev
62
+ ? `<script${n}>try{new EventSource("/_glash/hmr").onmessage=function(e){if(e.data==="reload")location.reload();};}catch(e){}</script>`
63
+ : '';
64
+ return `\n${fav}${off}${hmr}\n</body>\n</html>`;
65
+ }
66
+
67
+ /** Full document (buffered). */
68
+ export function renderDocument(opts = {}) {
69
+ const bodyHtml = typeof opts.body === 'object' && opts.body?.__raw ? opts.body.__raw : String(opts.body ?? '');
70
+ return shellOpen(opts) + bodyHtml + shellTail(opts);
71
+ }
72
+
73
+ /** Streaming document: `open` flushed before the body renders, `tail` after. */
74
+ export function documentParts(opts = {}) {
75
+ return { open: shellOpen(opts), tail: shellTail(opts) };
70
76
  }
@@ -13,7 +13,7 @@
13
13
  // A `.jsx`/`.tsx` route exports a default component and (optionally) an async
14
14
  // `getServerData(ctx)` that returns props for SSR + hydration. Plain `.mjs`
15
15
  // HTML-render routes keep working with zero deps.
16
- import { promises as fs } from 'node:fs';
16
+ import { promises as fs, existsSync } from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { createHash } from 'node:crypto';
19
19
  import { pathToFileURL } from 'node:url';
@@ -49,65 +49,102 @@ export function routeId(file) {
49
49
  const serverCache = new Map();
50
50
  const clientCache = new Map();
51
51
 
52
- /** Compile a JSX/TSX route for the server and import it (default component + getServerData). */
53
- export async function loadComponentRoute(file, root, dev) {
52
+ /** Clear compiled-route caches (used by dev live-reload on file change). */
53
+ export function clearJsxCaches() {
54
+ serverCache.clear();
55
+ clientCache.clear();
56
+ }
57
+
58
+ /**
59
+ * Nested layouts: collect `_layout.{jsx,tsx}` from the routes root down to the
60
+ * page's directory (root-first). Each wraps its children, Next-style.
61
+ */
62
+ export function findLayouts(routesDir, pageFile) {
63
+ const root = path.resolve(routesDir);
64
+ const pageDir = path.dirname(path.resolve(pageFile));
65
+ const rel = path.relative(root, pageDir);
66
+ const dirs = [root];
67
+ let acc = root;
68
+ for (const part of rel ? rel.split(path.sep) : []) { acc = path.join(acc, part); dirs.push(acc); }
69
+ const layouts = [];
70
+ for (const d of dirs) {
71
+ for (const name of ['_layout.jsx', '_layout.tsx']) {
72
+ const f = path.join(d, name);
73
+ if (existsSync(f)) { layouts.push(f); break; }
74
+ }
75
+ }
76
+ return layouts;
77
+ }
78
+
79
+ const compId = (pageFile, layouts) => routeId(pageFile + '|' + layouts.join('|'));
80
+
81
+ function serverEntry(pageFile, layouts) {
82
+ const lines = [`import * as __p from ${JSON.stringify(pageFile)};`];
83
+ layouts.forEach((f, i) => lines.push(`import __L${i} from ${JSON.stringify(f)};`));
84
+ lines.push('export const Page = __p.default;', 'export const getServerData = __p.getServerData;', 'export const title = __p.title;');
85
+ lines.push(`export const layouts = [${layouts.map((_, i) => `__L${i}`).join(',')}];`);
86
+ return lines.join('\n');
87
+ }
88
+
89
+ function clientEntry(pageFile, layouts) {
90
+ const imports = [`import { hydrate, h } from 'preact';`, `import Page from ${JSON.stringify(pageFile)};`];
91
+ layouts.forEach((f, i) => imports.push(`import __L${i} from ${JSON.stringify(f)};`));
92
+ return `${imports.join('\n')}
93
+ const layouts = [${layouts.map((_, i) => `__L${i}`).join(',')}];
94
+ const el = document.getElementById('glash-root');
95
+ const pe = document.getElementById('glash-props');
96
+ let props = {};
97
+ try { props = pe ? JSON.parse(pe.textContent) : {}; } catch {}
98
+ let tree = h(Page, props);
99
+ for (let i = layouts.length - 1; i >= 0; i--) tree = h(layouts[i], props, tree);
100
+ if (el) hydrate(tree, el);`;
101
+ }
102
+
103
+ /** Compile page + its layout chain into a server module ({ Page, layouts, getServerData, title }). */
104
+ export async function loadComponentRoute(pageFile, layouts, root, dev) {
54
105
  const eb = await esbuild();
55
106
  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');
107
+ const id = compId(pageFile, layouts);
108
+ if (!dev && serverCache.has(id)) return serverCache.get(id);
109
+ const out = path.join(root, '.glash', 'server', id + '.mjs');
58
110
  await fs.mkdir(path.dirname(out), { recursive: true });
59
111
  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).
112
+ stdin: { contents: serverEntry(pageFile, layouts), resolveDir: path.dirname(pageFile), loader: 'js', sourcefile: 'glash-server-entry.js' },
113
+ bundle: true, platform: 'node', format: 'esm', jsx: 'automatic', jsxImportSource: 'preact',
68
114
  external: ['preact', 'preact/*', 'preact-render-to-string'],
69
- outfile: out,
70
- logLevel: 'silent',
115
+ outfile: out, logLevel: 'silent',
71
116
  });
72
117
  const mod = await import(pathToFileURL(out).href + (dev ? `?t=${Date.now()}` : ''));
73
- if (!dev) serverCache.set(file, mod);
118
+ if (!dev) serverCache.set(id, mod);
74
119
  return mod;
75
120
  }
76
121
 
77
- /** Build the browser hydration bundle for a route (preact bundled in). */
78
- export async function clientBundle(file, dev) {
122
+ /** Build the browser hydration bundle for page + layouts (preact bundled in). */
123
+ export async function clientBundle(pageFile, layouts, dev) {
79
124
  const eb = await esbuild();
80
125
  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);`;
126
+ const id = compId(pageFile, layouts);
127
+ if (!dev && clientCache.has(id)) return clientCache.get(id);
91
128
  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',
129
+ stdin: { contents: clientEntry(pageFile, layouts), resolveDir: path.dirname(pageFile), loader: 'jsx', sourcefile: 'glash-client-entry.jsx' },
130
+ bundle: true, platform: 'browser', format: 'esm', minify: !dev, jsx: 'automatic', jsxImportSource: 'preact',
131
+ write: false, logLevel: 'silent',
101
132
  });
102
133
  const js = res.outputFiles[0].text;
103
- if (!dev) clientCache.set(file, js);
134
+ if (!dev) clientCache.set(id, js);
104
135
  return js;
105
136
  }
106
137
 
107
- /** Server-render a route component to an HTML string. */
138
+ function compose(h, layouts, Page, props) {
139
+ let tree = h(Page, props);
140
+ for (let i = layouts.length - 1; i >= 0; i--) tree = h(layouts[i], props, tree);
141
+ return tree;
142
+ }
143
+
144
+ /** Server-render page + layouts to an HTML string. */
108
145
  export async function renderComponent(mod, props) {
109
146
  const rt = await preactRuntime();
110
147
  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));
148
+ if (typeof mod.Page !== 'function') throw new Error('JSX route must default-export a component');
149
+ return rt.renderToString(compose(rt.h, mod.layouts || [], mod.Page, props));
113
150
  }
@@ -16,6 +16,8 @@ export async function discoverRoutes(routesDir) {
16
16
  await walk(root, root, files);
17
17
  const routes = files
18
18
  .filter((f) => /\.(mjs|js|jsx|tsx)$/.test(f.rel))
19
+ // `_`-prefixed files are private (layouts, helpers) — not routes.
20
+ .filter((f) => !f.rel.split('/').some((seg) => seg.startsWith('_')))
19
21
  .map((f) => toRoute(f.rel, f.file));
20
22
  // Most specific first: static segments beat params beat catch-all.
21
23
  routes.sort((a, b) => b.score - a.score);
@@ -6,13 +6,13 @@
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 } from 'node:fs';
9
+ import { promises as fs, existsSync, statSync, watch } 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 } from './router.mjs';
14
- import { renderDocument } from './html.mjs';
15
- import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId } from './jsx.mjs';
14
+ import { renderDocument, documentParts } from './html.mjs';
15
+ import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId, findLayouts, clearJsxCaches } from './jsx.mjs';
16
16
  import { securityHeaders } from '../security/headers.mjs';
17
17
  import { loadConfig } from '../config.mjs';
18
18
 
@@ -36,17 +36,37 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
36
36
  const importRoute = (file) =>
37
37
  import(pathToFileURL(file).href + (dev ? `?t=${Date.now()}` : ''));
38
38
 
39
+ // Dev live-reload: watch routes/, drop compiled caches, and push a reload
40
+ // event to every connected browser over Server-Sent Events.
41
+ const hmrClients = new Set();
42
+ if (dev) {
43
+ try {
44
+ watch(routesDir, { recursive: true }, () => {
45
+ clearJsxCaches();
46
+ for (const c of hmrClients) c.write('data: reload\n\n');
47
+ });
48
+ } catch { /* fs.watch recursive may be unsupported on some platforms */ }
49
+ }
50
+
39
51
  const server = http.createServer(async (req, res) => {
40
52
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
41
53
  const pathname = safeDecode(url.pathname);
42
54
  try {
43
55
  if (dev) routes = await discoverRoutes(routesDir);
56
+ // Dev live-reload SSE channel.
57
+ if (dev && pathname === '/_glash/hmr') {
58
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
59
+ res.write('retry: 1000\n\n');
60
+ hmrClients.add(res);
61
+ req.on('close', () => hmrClients.delete(res));
62
+ return;
63
+ }
44
64
  // Client hydration bundles: /_glash/<routeId>.js
45
65
  if (pathname.startsWith('/_glash/')) {
46
66
  const id = pathname.slice('/_glash/'.length).replace(/\.js$/, '');
47
67
  const comp = routes.find((r) => isComponentRoute(r.file) && routeId(r.file) === id);
48
68
  if (!comp) return send(res, 404, 'text/plain', 'not found', secHeaders);
49
- const js = await clientBundle(comp.file, dev);
69
+ const js = await clientBundle(comp.file, findLayouts(routesDir, comp.file), dev);
50
70
  return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
51
71
  }
52
72
  if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
@@ -58,10 +78,10 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
58
78
  return await handleApi(res, mod, req, ctx, secHeaders);
59
79
  }
60
80
  if (isComponentRoute(match.route.file)) {
61
- return await handleComponentPage(res, match.route, ctx, cfg, secHeaders, root, dev);
81
+ return await handleComponentPage(res, match.route, ctx, cfg, secHeaders, root, routesDir, dev);
62
82
  }
63
83
  const mod = await importRoute(match.route.file);
64
- return await handlePage(res, mod, ctx, cfg, secHeaders);
84
+ return await handlePage(res, mod, ctx, cfg, secHeaders, dev);
65
85
  } catch (err) {
66
86
  const msg = dev ? `glashjs error:\n${err?.stack || err}` : 'Internal Server Error';
67
87
  send(res, 500, 'text/plain; charset=utf-8', msg, secHeaders);
@@ -89,7 +109,7 @@ async function handleApi(res, mod, req, ctx, secHeaders) {
89
109
  send(res, 200, 'application/json', JSON.stringify(result ?? null), secHeaders);
90
110
  }
91
111
 
92
- async function handlePage(res, mod, ctx, cfg, secHeaders) {
112
+ async function handlePage(res, mod, ctx, cfg, secHeaders, dev) {
93
113
  const render = mod.default;
94
114
  if (typeof render !== 'function') return send(res, 500, 'text/plain', 'route has no default export', secHeaders);
95
115
  const out = await render(ctx);
@@ -101,30 +121,28 @@ async function handlePage(res, mod, ctx, cfg, secHeaders) {
101
121
  body: page.body ?? '',
102
122
  offline: cfg.offline,
103
123
  animatedFavicon: !!cfg.animatedFavicon,
104
- nonce,
124
+ nonce, dev,
105
125
  });
106
126
  send(res, page.status || 200, 'text/html; charset=utf-8', docHtml, { ...pageHeaders(cfg, secHeaders, nonce), ...(page.headers || {}) });
107
127
  }
108
128
 
109
- async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, dev) {
110
- const mod = await loadComponentRoute(route.file, root, dev);
129
+ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, routesDir, dev) {
130
+ const layouts = findLayouts(routesDir, route.file);
131
+ const mod = await loadComponentRoute(route.file, layouts, root, dev);
111
132
  const props = (typeof mod.getServerData === 'function' ? await mod.getServerData(ctx) : {}) || {};
112
- const rendered = await renderComponent(mod, props);
113
133
  const id = routeId(route.file);
114
134
  const nonce = randomBytes(16).toString('base64');
115
135
  // Props in a non-executed JSON block (CSP-safe); hydration bundle is an
116
136
  // external 'self' module — both pass the strict CSP without 'unsafe-inline'.
117
137
  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,
138
+ const { open, tail } = documentParts({
139
+ title: mod.title || cfg.name, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
126
140
  });
127
- send(res, 200, 'text/html; charset=utf-8', docHtml, pageHeaders(cfg, secHeaders, nonce));
141
+ res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
142
+ res.write(open); // flush the shell first, before rendering the component
143
+ const rendered = await renderComponent(mod, props);
144
+ res.write(`<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${id}.js"></script>`);
145
+ res.end(tail);
128
146
  }
129
147
 
130
148
  // Per-request page headers: a fresh CSP carrying this request's script nonce, so