glashjs 0.9.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 +3 -2
- package/package.json +1 -1
- package/src/server/jsx.mjs +17 -1
- package/src/server/server.mjs +29 -6
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 **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. **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)
|
|
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
|
|
|
@@ -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
|
-
- [
|
|
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.
|
|
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": {
|
package/src/server/jsx.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/server/server.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|