glashjs 0.8.0 → 0.10.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
@@ -116,7 +116,7 @@ export default function Counter({ start = 0 }) {
116
116
 
117
117
  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'`).
118
118
 
119
- **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.
119
+ **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 **instant HMR** (`glash dev` does an in-place soft re-render on save over SSE — no full reload, no flash, and scroll/focus/form-input are preserved across the swap) are all in. **Suspense streaming** is in too — wrap a data-dependent subtree in `<Suspense fallback={…}>` (from `preact/compat`) and the shell + fallback flush immediately, then each boundary streams in as its data resolves (`renderToPipeableStream`), with preact's inline swap scripts nonce-injected so the strict CSP holds. **Honest scope:** uses Preact (React-compatible via `preact/compat`), not React; HMR preserves DOM/scroll/input state but **not** component `useState` (that's React-Fast-Refresh via `@prefresh`, still ahead).
120
120
 
121
121
  ## Usage
122
122
 
@@ -169,7 +169,7 @@ animatedFavicon: true, // bundled animated glash mark (d
169
169
  - [x] Client-JS bundling (esbuild) per route
170
170
  - [x] Nested layouts (`_layout.jsx` composing root→leaf, server + hydration)
171
171
  - [x] Streaming SSR (shell flushed before the component renders)
172
- - [x] Dev live-reload over SSE (auto-refresh on save)
172
+ - [x] Instant HMR — in-place soft re-render on save (no full reload, no flash; preserves scroll, focus, and form input)
173
173
  - [x] `<Image>` — zero-config `<picture>` with AVIF/WebP from the optimizer (beats next/image: no runtime image server)
174
174
  - [x] `<Video>` — `<video>` with AV1/WebM + mp4 fallback + auto poster
175
175
  - [x] File-based middleware (`_middleware.mjs`, root→leaf) — auth, redirects, headers
@@ -178,7 +178,8 @@ animatedFavicon: true, // bundled animated glash mark (d
178
178
  - [x] `<Link>` client-side navigation (SPA swap of `#glash-root` + re-hydrate; progressive-enhancement `<a>`)
179
179
  - [x] `glash deploy` → glashdb (builds, then hands off to the `glashdb` CLI)
180
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
181
+ - [x] Suspense streaming (`renderToPipeableStream` — fallback in the shell, each boundary streams in as its data resolves; CSP-safe via per-request nonce injection)
182
+ - [ ] React-Fast-Refresh (`useState` preservation via `@prefresh`; browser-verified), edge adapter
182
183
  - [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
183
184
  - [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
184
185
  - [ ] `glash deploy` → glashdb hosting in one command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.8.0",
3
+ "version": "0.10.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": {
@@ -57,9 +57,11 @@ function shellTail({ offline = true, animatedFavicon = true, dev = false, nonce
57
57
  const off = offline
58
58
  ? `<script type="module"${n}>try{const m=await import("/glash-offline.mjs");m.registerGlashOffline&&m.registerGlashOffline();}catch{}</script>`
59
59
  : '';
60
- // Dev live-reload: a nonce'd inline script (CSP-safe) that reloads on change.
60
+ // Dev HMR: a nonce'd inline script (CSP-safe). On a change event it does an
61
+ // in-place soft refresh (no full reload, keeps scroll/focus/inputs), falling
62
+ // back to a full reload if the nav runtime hasn't loaded yet.
61
63
  const hmr = dev
62
- ? `<script${n}>try{new EventSource("/_glash/hmr").onmessage=function(e){if(e.data==="reload")location.reload();};}catch(e){}</script>`
64
+ ? `<script${n}>try{new EventSource("/_glash/hmr").onmessage=function(e){if(e.data==="reload"){if(window.__glashSoftRefresh){window.__glashSoftRefresh();}else{location.reload();}}};}catch(e){}</script>`
63
65
  : '';
64
66
  // Client-side navigation runtime (external 'self' module, CSP-safe).
65
67
  const nav = '<script type="module" src="/_glash/nav.js"></script>';
@@ -148,10 +148,26 @@ function compose(h, layouts, Page, props) {
148
148
  return tree;
149
149
  }
150
150
 
151
- /** Server-render page + layouts to an HTML string. */
151
+ /** Server-render page + layouts to an HTML string (buffered). */
152
152
  export async function renderComponent(mod, props) {
153
153
  const rt = await preactRuntime();
154
154
  if (!rt) throw new Error(MISSING);
155
155
  if (typeof mod.Page !== 'function') throw new Error('JSX route must default-export a component');
156
156
  return rt.renderToString(compose(rt.h, mod.layouts || [], mod.Page, props));
157
157
  }
158
+
159
+ /** Compose the page + layouts into a vnode (for streaming). */
160
+ export async function composeVNode(mod, props) {
161
+ const rt = await preactRuntime();
162
+ if (!rt) throw new Error(MISSING);
163
+ if (typeof mod.Page !== 'function') throw new Error('JSX route must default-export a component');
164
+ return compose(rt.h, mod.layouts || [], mod.Page, props);
165
+ }
166
+
167
+ let _pipeable;
168
+ /** The Node Suspense-streaming renderer, if the installed preact-render-to-string supports it. */
169
+ export async function getPipeableRenderer() {
170
+ if (_pipeable !== undefined) return _pipeable;
171
+ try { _pipeable = (await import('preact-render-to-string/stream-node')).renderToPipeableStream; } catch { _pipeable = null; }
172
+ return _pipeable;
173
+ }
@@ -1,35 +1,72 @@
1
- // Source for /_glash/nav.js — the client-side navigation runtime.
2
- // Intercepts clicks on <a data-glash-link>, fetches the route as a partial
3
- // (X-Glash-Nav: 1 -> JSON { title, html, props, bundle }), swaps #glash-root,
4
- // updates the props block + <title>, pushes history, and re-hydrates by
5
- // importing the new route's bundle. Falls back to a full navigation on any error.
6
- export const NAV_CLIENT = `// glashjs client navigation
7
- async function navigate(href, push) {
8
- let data;
1
+ // Source for /_glash/nav.js — the client-side navigation + dev HMR runtime.
2
+ //
3
+ // Navigation: intercepts <a data-glash-link> clicks, fetches the route as a
4
+ // partial (X-Glash-Nav: 1 -> JSON { title, html, props, bundle }), swaps
5
+ // #glash-root, updates <title> + history, and re-hydrates by importing the
6
+ // route's bundle. Falls back to a full navigation on any error.
7
+ //
8
+ // Dev HMR (window.__glashSoftRefresh): on a file change the dev server pushes
9
+ // a reload over SSE; instead of a full page reload, we re-render the current
10
+ // route in place — no white flash — and restore scroll, focus, and form input
11
+ // across the swap. (Note: component useState resets on re-render; preserving
12
+ // that is React-Fast-Refresh territory, which needs @prefresh.)
13
+ export const NAV_CLIENT = `// glashjs client navigation + dev HMR
14
+ async function navigate(href, push, keepScroll) {
15
+ var data;
9
16
  try {
10
- const res = await fetch(href, { headers: { 'X-Glash-Nav': '1' }, credentials: 'same-origin' });
17
+ var res = await fetch(href, { headers: { 'X-Glash-Nav': '1' }, credentials: 'same-origin' });
11
18
  if (!res.ok) throw 0;
12
19
  data = await res.json();
13
- } catch { location.href = href; return; }
14
- const root = document.getElementById('glash-root');
20
+ } catch (e) { location.href = href; return; }
21
+ var root = document.getElementById('glash-root');
15
22
  if (!root || !data || typeof data.html !== 'string') { location.href = href; return; }
16
23
  if (data.title) document.title = data.title;
17
- let pe = document.getElementById('glash-props');
24
+ var pe = document.getElementById('glash-props');
18
25
  if (!pe) { pe = document.createElement('script'); pe.type = 'application/json'; pe.id = 'glash-props'; document.head.appendChild(pe); }
19
26
  pe.textContent = JSON.stringify(data.props || {});
20
27
  root.innerHTML = data.html;
21
28
  if (push) history.pushState({ glash: 1 }, '', href);
22
- window.scrollTo(0, 0);
23
- if (data.bundle) { try { await import(data.bundle); } catch {} }
29
+ if (!keepScroll) window.scrollTo(0, 0);
30
+ if (data.bundle) { try { await import(data.bundle + '?v=' + Date.now()); } catch (e) {} }
24
31
  }
25
- document.addEventListener('click', (e) => {
32
+
33
+ document.addEventListener('click', function (e) {
26
34
  if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
27
- const a = e.target.closest && e.target.closest('a[data-glash-link]');
35
+ var a = e.target.closest && e.target.closest('a[data-glash-link]');
28
36
  if (!a) return;
29
- const href = a.getAttribute('href');
37
+ var href = a.getAttribute('href');
30
38
  if (!href || a.target || /^([a-z]+:)?\\/\\//i.test(href) || href.startsWith('#') || href.startsWith('mailto:')) return;
31
39
  e.preventDefault();
32
40
  if (href !== location.pathname + location.search) navigate(href, true);
33
41
  });
34
- window.addEventListener('popstate', () => navigate(location.pathname + location.search, false));
42
+ window.addEventListener('popstate', function () { navigate(location.pathname + location.search, false); });
43
+
44
+ window.__glashNavigate = navigate;
45
+
46
+ // Dev HMR: re-render the current route in place, preserving scroll/focus/inputs.
47
+ window.__glashSoftRefresh = async function () {
48
+ var root = document.getElementById('glash-root');
49
+ if (!root) { location.reload(); return; }
50
+ var sx = window.scrollX, sy = window.scrollY;
51
+ var active = document.activeElement;
52
+ var focusName = active && active.getAttribute && active.getAttribute('name');
53
+ var selStart = active && active.selectionStart;
54
+ var selEnd = active && active.selectionEnd;
55
+ var values = {};
56
+ root.querySelectorAll('input[name],textarea[name],select[name]').forEach(function (el) {
57
+ values[el.name] = (el.type === 'checkbox' || el.type === 'radio') ? el.checked : el.value;
58
+ });
59
+ await navigate(location.pathname + location.search, false, true);
60
+ root.querySelectorAll('input[name],textarea[name],select[name]').forEach(function (el) {
61
+ if (Object.prototype.hasOwnProperty.call(values, el.name)) {
62
+ if (el.type === 'checkbox' || el.type === 'radio') el.checked = values[el.name];
63
+ else el.value = values[el.name];
64
+ }
65
+ });
66
+ window.scrollTo(sx, sy);
67
+ if (focusName) {
68
+ var el = root.querySelector('[name="' + focusName + '"]');
69
+ if (el) { el.focus(); try { el.selectionStart = selStart; el.selectionEnd = selEnd; } catch (e) {} }
70
+ }
71
+ };
35
72
  `;
@@ -7,13 +7,14 @@
7
7
  // Every response carries the secure-by-default headers.
8
8
  import http from 'node:http';
9
9
  import { promises as fs, existsSync, statSync, watch, createReadStream } from 'node:fs';
10
+ import { Transform } from 'node:stream';
10
11
  import { randomBytes } from 'node:crypto';
11
12
  import path from 'node:path';
12
13
  import { pathToFileURL } from 'node:url';
13
14
  import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
14
15
  import { renderDocument, documentParts, renderMeta, escapeHtml } from './html.mjs';
15
16
  import { NAV_CLIENT } from './nav-client.mjs';
16
- import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId, findLayouts, clearJsxCaches } from './jsx.mjs';
17
+ import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, composeVNode, getPipeableRenderer, routeId, findLayouts, clearJsxCaches } from './jsx.mjs';
17
18
  import { securityHeaders } from '../security/headers.mjs';
18
19
  import { loadConfig } from '../config.mjs';
19
20
 
@@ -177,19 +178,41 @@ async function handleComponentPage(res, route, ctx, cfg, secHeaders, root, route
177
178
  const { open, tail } = documentParts({
178
179
  title, head, offline: cfg.offline, animatedFavicon: !!cfg.animatedFavicon, nonce, dev,
179
180
  });
181
+ const bundleTag = `</div><script type="module" src="/_glash/${id}.js"></script>`;
180
182
  res.writeHead(200, { ...pageHeaders(cfg, secHeaders, nonce), 'content-type': 'text/html; charset=utf-8' });
181
- res.write(open); // flush the shell first, before rendering the component
183
+ res.write(open + '<div id="glash-root">'); // flush the shell before rendering
184
+
185
+ // True Suspense streaming: render the boundary's fallback into the shell now,
186
+ // then stream each boundary's real content as its data resolves. preact emits
187
+ // inline <script> swap tags, so we inject this request's nonce into the stream
188
+ // to keep the strict CSP intact.
189
+ const pipeable = await getPipeableRenderer();
190
+ if (pipeable) {
191
+ try {
192
+ const vnode = await composeVNode(mod, props);
193
+ const inject = new Transform({
194
+ transform(chunk, _enc, cb) {
195
+ cb(null, Buffer.from(chunk.toString('utf8').replace(/<script(?=[\s>])/g, `<script nonce="${nonce}"`)));
196
+ },
197
+ });
198
+ inject.pipe(res, { end: false });
199
+ inject.on('end', () => res.end(bundleTag + tail));
200
+ const stream = pipeable(vnode, { onError: () => { /* boundary error — keep the shell */ } });
201
+ stream.pipe(inject);
202
+ return;
203
+ } catch { /* fall through to buffered render */ }
204
+ }
205
+
206
+ // Fallback (no streaming renderer): buffered render.
182
207
  try {
183
208
  const rendered = await renderComponent(mod, props);
184
- res.write(`<div id="glash-root">${rendered}</div><script type="module" src="/_glash/${id}.js"></script>`);
209
+ res.write(rendered + bundleTag);
185
210
  res.end(tail);
186
211
  } 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
212
  res.write(dev
190
213
  ? `<pre style="color:#ff6b6b;white-space:pre-wrap;font:13px ui-monospace,monospace;padding:1rem">${escapeHtml(String(err?.stack || err))}</pre>`
191
214
  : '<p style="font:16px system-ui;color:#9aa0aa;padding:1rem">Something went wrong rendering this page.</p>');
192
- res.end(tail);
215
+ res.end(`</div>${tail}`);
193
216
  }
194
217
  }
195
218