glashjs 0.9.0 → 0.11.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
@@ -75,8 +75,12 @@ export const POST = (ctx) => json({ created: ctx.body }, { status: 201 });
75
75
  ```
76
76
 
77
77
  ```bash
78
- glash dev # dev server: routing + SSR + API, live route reload
78
+ glash dev # dev server (live reload) prints a Local + Network (LAN IP) URL
79
+ # ➜ Local: http://localhost:3000
80
+ # ➜ Network: http://192.168.1.57:3000 (open from your phone/other devices)
79
81
  glash serve # production server over routes/ + built assets (Brotli-negotiated)
82
+ glash update # update glashjs to the latest published version
83
+ glash deploy # build, then deploy to glashdb
80
84
  ```
81
85
 
82
86
  **`<Image>`** (better than `next/image` — no runtime image server, uses the build's AVIF/WebP):
@@ -116,7 +120,7 @@ export default function Counter({ start = 0 }) {
116
120
 
117
121
  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
122
 
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); streaming is shell-flush, not Suspense-chunked.
123
+ **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
124
 
121
125
  ## Usage
122
126
 
@@ -178,7 +182,8 @@ animatedFavicon: true, // bundled animated glash mark (d
178
182
  - [x] `<Link>` client-side navigation (SPA swap of `#glash-root` + re-hydrate; progressive-enhancement `<a>`)
179
183
  - [x] `glash deploy` → glashdb (builds, then hands off to the `glashdb` CLI)
180
184
  - [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
- - [ ] React-Fast-Refresh (`useState` preservation via `@prefresh`), Suspense streaming, edge adapter
185
+ - [x] Suspense streaming (`renderToPipeableStream` fallback in the shell, each boundary streams in as its data resolves; CSP-safe via per-request nonce injection)
186
+ - [ ] React-Fast-Refresh (`useState` preservation via `@prefresh`; browser-verified), edge adapter
182
187
  - [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
183
188
  - [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
184
189
  - [ ] `glash deploy` → glashdb hosting in one command
package/bin/glash.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // glashjs CLI
3
3
  import { readFileSync } from 'node:fs';
4
+ import os from 'node:os';
4
5
  import { build } from '../src/build.mjs';
5
6
  import { optimizeAssets } from '../src/assets/optimize.mjs';
6
7
  import { createGlashServer } from '../src/server/server.mjs';
7
8
  import { deploy } from '../src/deploy.mjs';
9
+ import { update } from '../src/update.mjs';
8
10
 
9
11
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
10
12
  const [, , cmd, ...rest] = process.argv;
@@ -14,17 +16,34 @@ function arg(name, fallback) {
14
16
  return i >= 0 && rest[i + 1] ? rest[i + 1] : fallback;
15
17
  }
16
18
 
19
+ // LAN IPv4 addresses, so the dev server prints a Network URL you can open from
20
+ // your phone or another device on the same network.
21
+ function lanAddresses() {
22
+ const out = [];
23
+ const ifaces = os.networkInterfaces();
24
+ for (const name of Object.keys(ifaces)) {
25
+ for (const i of ifaces[name] || []) {
26
+ if (i.family === 'IPv4' && !i.internal) out.push(i.address);
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
17
32
  async function serve(dev) {
18
33
  const root = arg('--root', process.cwd());
19
34
  const { listen, cfg, routes } = await createGlashServer({ root, dev });
20
35
  const port = Number(arg('--port', cfg.port || 3000));
21
- const { host } = await listen(port);
36
+ await listen(port);
22
37
  const pages = routes.filter((r) => !r.isApi).length;
23
38
  const apis = routes.filter((r) => r.isApi).length;
24
39
  console.log(`\nglashjs ${dev ? 'dev' : 'serve'} — "${cfg.name}"`);
25
40
  console.log(` ${pages} page route(s), ${apis} api route(s)`);
26
41
  routes.forEach((r) => console.log(` ${r.isApi ? 'api ' : 'page'} ${r.pattern}`));
27
- console.log(`\n ▶ http://localhost:${port}${dev ? ' (live route reload)' : ''}\n`);
42
+ console.log('');
43
+ console.log(` ➜ Local: http://localhost:${port}`);
44
+ for (const ip of lanAddresses()) console.log(` ➜ Network: http://${ip}:${port} (preview on other devices)`);
45
+ if (dev) console.log('\n live reload on save · ctrl-c to stop');
46
+ console.log('');
28
47
  }
29
48
 
30
49
  async function main() {
@@ -38,6 +57,10 @@ async function main() {
38
57
  await deploy({ root: arg('--root', process.cwd()), dryRun: rest.includes('--dry-run'), args: passthrough });
39
58
  break;
40
59
  }
60
+ case 'update':
61
+ case 'upgrade':
62
+ await update({ root: arg('--root', process.cwd()) });
63
+ break;
41
64
  case 'dev':
42
65
  await serve(true);
43
66
  break;
@@ -66,6 +89,7 @@ Usage:
66
89
  glash serve [--port 3000] Run the production server over routes/ + built assets
67
90
  glash build [--root <dir>] Optimize assets, generate offline SW + PWA + security manifests
68
91
  glash deploy [--dry-run] Build, then deploy to glashdb (hands off to the glashdb CLI)
92
+ glash update Update glashjs to the latest published version
69
93
  glash optimize [<dir>] Just run the asset optimizer over a directory
70
94
  glash version Print version
71
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glashjs",
3
- "version": "0.9.0",
3
+ "version": "0.11.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/index.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // glashjs public API
2
2
  export { defineConfig, loadConfig, DEFAULT_CONFIG } from './config.mjs';
3
3
  export { build } from './build.mjs';
4
+ export { deploy } from './deploy.mjs';
5
+ export { update } from './update.mjs';
4
6
  export { optimizeAssets } from './assets/optimize.mjs';
5
7
  export { generateAnimatedFavicon } from './assets/animated-favicon.mjs';
6
8
  export { generateServiceWorker } from './offline/generate-sw.mjs';
@@ -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
+ }
@@ -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
 
package/src/update.mjs ADDED
@@ -0,0 +1,39 @@
1
+ // glashjs update — bump the installed glashjs to the latest published version,
2
+ // using whichever package manager the project uses (npm/pnpm/yarn/bun).
3
+ import { spawn } from 'node:child_process';
4
+ import { readFileSync, existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ function installedVersion(root) {
8
+ try {
9
+ return JSON.parse(readFileSync(path.join(root, 'node_modules/glashjs/package.json'), 'utf8')).version;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function packageManager(root) {
16
+ if (existsSync(path.join(root, 'pnpm-lock.yaml'))) return { cmd: 'pnpm', args: ['add', 'glashjs@latest'] };
17
+ if (existsSync(path.join(root, 'yarn.lock'))) return { cmd: 'yarn', args: ['add', 'glashjs@latest'] };
18
+ if (existsSync(path.join(root, 'bun.lockb'))) return { cmd: 'bun', args: ['add', 'glashjs@latest'] };
19
+ return { cmd: 'npm', args: ['install', 'glashjs@latest'] };
20
+ }
21
+
22
+ function run(cmd, args, root) {
23
+ return new Promise((resolve, reject) => {
24
+ const child = spawn(cmd, args, { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' });
25
+ child.on('error', reject);
26
+ child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`))));
27
+ });
28
+ }
29
+
30
+ export async function update({ root = process.cwd(), log = console.log } = {}) {
31
+ const current = installedVersion(root);
32
+ const { cmd, args } = packageManager(root);
33
+ log(`glashjs update — current: ${current ?? 'not installed'}\n $ ${cmd} ${args.join(' ')}\n`);
34
+ await run(cmd, args, root);
35
+ const next = installedVersion(root);
36
+ if (next && next === current) log(`\n✓ already on the latest version (${next})`);
37
+ else log(`\n✓ glashjs is now ${next ?? 'installed'}${current ? ` (was ${current})` : ''}`);
38
+ return { from: current, to: next };
39
+ }