glashjs 0.3.0 → 0.5.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 +18 -1
- package/package.json +5 -2
- package/src/build.mjs +36 -1
- package/src/components/image.mjs +42 -0
- package/src/components/video.mjs +27 -0
- package/src/index.mjs +4 -2
- package/src/server/jsx.mjs +10 -3
- package/src/server/router.mjs +24 -1
- package/src/server/server.mjs +35 -5
package/README.md
CHANGED
|
@@ -77,6 +77,19 @@ glash dev # dev server: routing + SSR + API, live route reload
|
|
|
77
77
|
glash serve # production server over routes/ + built assets (Brotli-negotiated)
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
**`<Image>`** (better than `next/image` — no runtime image server, uses the build's AVIF/WebP):
|
|
81
|
+
```jsx
|
|
82
|
+
import { Image } from 'glashjs/image';
|
|
83
|
+
<Image src="/hero.png" alt="Hero" width={1200} height={630} />
|
|
84
|
+
// -> <picture><source …avif><source …webp><img loading=lazy decoding=async></picture>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**File-based middleware** (`_middleware.mjs`, runs root→leaf before the route):
|
|
88
|
+
```js
|
|
89
|
+
import { redirect } from 'glashjs';
|
|
90
|
+
export default (ctx) => { if (!ctx.headers.authorization) return redirect('/login'); };
|
|
91
|
+
```
|
|
92
|
+
|
|
80
93
|
Every response carries the secure-by-default headers; static files are served from the build with Brotli negotiation.
|
|
81
94
|
|
|
82
95
|
### JSX components + client hydration (new in 0.2)
|
|
@@ -148,7 +161,11 @@ animatedFavicon: true, // bundled animated glash mark (d
|
|
|
148
161
|
- [x] Nested layouts (`_layout.jsx` composing root→leaf, server + hydration)
|
|
149
162
|
- [x] Streaming SSR (shell flushed before the component renders)
|
|
150
163
|
- [x] Dev live-reload over SSE (auto-refresh on save)
|
|
151
|
-
- [
|
|
164
|
+
- [x] `<Image>` — zero-config `<picture>` with AVIF/WebP from the optimizer (beats next/image: no runtime image server)
|
|
165
|
+
- [x] `<Video>` — `<video>` with AV1/WebM + mp4 fallback + auto poster
|
|
166
|
+
- [x] File-based middleware (`_middleware.mjs`, root→leaf) — auth, redirects, headers
|
|
167
|
+
- [x] Production route precompile (`glash build` bakes server modules + minified client bundles → no runtime esbuild on `glash serve`)
|
|
168
|
+
- [ ] State-preserving fast-refresh, Suspense streaming, `glash deploy` → glashdb
|
|
152
169
|
- [ ] `<Image>` / `<Video>` components that emit `<picture>`/`<source>` from the manifest
|
|
153
170
|
- [ ] Edge adapter for the glashdb Worker (serve `.br`/`.avif` by `Accept`)
|
|
154
171
|
- [ ] `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.5.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": {
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.mjs",
|
|
11
11
|
"./config": "./src/config.mjs",
|
|
12
|
-
"./security": "./src/security/headers.mjs"
|
|
12
|
+
"./security": "./src/security/headers.mjs",
|
|
13
|
+
"./image": "./src/components/image.mjs",
|
|
14
|
+
"./video": "./src/components/video.mjs",
|
|
15
|
+
"./package.json": "./package.json"
|
|
13
16
|
},
|
|
14
17
|
"files": [
|
|
15
18
|
"bin",
|
package/src/build.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Ties the pieces together: optimize assets -> derive a content version ->
|
|
3
3
|
// generate the offline Service Worker + PWA manifest -> emit security headers
|
|
4
4
|
// + a deploy manifest the glashdb edge (or any server) can consume.
|
|
5
|
-
import { promises as fs } from 'node:fs';
|
|
5
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
@@ -10,8 +10,32 @@ import { optimizeAssets } from './assets/optimize.mjs';
|
|
|
10
10
|
import { generateAnimatedFavicon } from './assets/animated-favicon.mjs';
|
|
11
11
|
import { generateServiceWorker } from './offline/generate-sw.mjs';
|
|
12
12
|
import { securityHeaders } from './security/headers.mjs';
|
|
13
|
+
import { discoverRoutes } from './server/router.mjs';
|
|
14
|
+
import { isComponentRoute, findLayouts, loadComponentRoute, clientBundle, routeId } from './server/jsx.mjs';
|
|
13
15
|
import { loadConfig } from './config.mjs';
|
|
14
16
|
|
|
17
|
+
// Precompile JSX routes: server modules (-> .glash/server) + minified client
|
|
18
|
+
// hydration bundles (-> outDir/_glash/<id>.js). Production `glash serve` then
|
|
19
|
+
// serves bundles statically and imports prebuilt server modules — no esbuild
|
|
20
|
+
// on the serving host's hot path.
|
|
21
|
+
async function buildRoutes(root, cfg, outDir, log) {
|
|
22
|
+
const routesDir = path.resolve(root, cfg.routesDir || 'routes');
|
|
23
|
+
if (!existsSync(routesDir)) return { compiled: 0 };
|
|
24
|
+
const routes = await discoverRoutes(routesDir);
|
|
25
|
+
const comp = routes.filter((r) => isComponentRoute(r.file));
|
|
26
|
+
if (!comp.length) return { compiled: 0 };
|
|
27
|
+
const bundleDir = path.join(outDir, '_glash');
|
|
28
|
+
await fs.mkdir(bundleDir, { recursive: true });
|
|
29
|
+
for (const r of comp) {
|
|
30
|
+
const layouts = findLayouts(routesDir, r.file);
|
|
31
|
+
await loadComponentRoute(r.file, layouts, root, false, true);
|
|
32
|
+
const js = await clientBundle(r.file, layouts, false);
|
|
33
|
+
await fs.writeFile(path.join(bundleDir, routeId(r.file) + '.js'), js);
|
|
34
|
+
log(` route ${r.pattern} -> _glash/${routeId(r.file)}.js`);
|
|
35
|
+
}
|
|
36
|
+
return { compiled: comp.length };
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
function deriveVersion(manifest) {
|
|
16
40
|
const h = createHash('sha256');
|
|
17
41
|
for (const [rel, entry] of Object.entries(manifest.assets)) h.update(rel + ':' + entry.hash);
|
|
@@ -47,6 +71,16 @@ export async function build({ root = process.cwd(), log = console.log } = {}) {
|
|
|
47
71
|
|
|
48
72
|
await fs.mkdir(outDir, { recursive: true });
|
|
49
73
|
|
|
74
|
+
// Precompile JSX routes (server modules + client bundles) for production.
|
|
75
|
+
let routesBuilt = { compiled: 0 };
|
|
76
|
+
try {
|
|
77
|
+
log('\nCompiling routes:');
|
|
78
|
+
routesBuilt = await buildRoutes(root, cfg, outDir, log);
|
|
79
|
+
if (!routesBuilt.compiled) log(' (no JSX routes)');
|
|
80
|
+
} catch (error) {
|
|
81
|
+
log(` ! route compile skipped: ${(error instanceof Error ? error.message : error)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
50
84
|
// Offline Service Worker + registration helper.
|
|
51
85
|
const offline = cfg.offline
|
|
52
86
|
? await generateServiceWorker(outDir, manifest, { dataPrefixes: cfg.dataPrefixes })
|
|
@@ -67,6 +101,7 @@ export async function build({ root = process.cwd(), log = console.log } = {}) {
|
|
|
67
101
|
log(` original ${kb(t.originalBytes)}`);
|
|
68
102
|
log(` optimized ${kb(t.optimizedBytes)}`);
|
|
69
103
|
log(` saved ${t.savedPercent}% (${kb(t.originalBytes - t.optimizedBytes)})`);
|
|
104
|
+
log(` routes ${routesBuilt.compiled} JSX route(s) precompiled (no runtime esbuild in prod)`);
|
|
70
105
|
log(` offline ${cfg.offline ? `${offline.precached} files precached (works offline after first visit)` : 'disabled'}`);
|
|
71
106
|
log(` favicon static glashdb logo${animated.enabled ? ' + animated (glash-favicon.mjs)' : ''}`);
|
|
72
107
|
log(` version ${version}`);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// glashjs <Image> — a zero-config image component that's better than next/image
|
|
2
|
+
// out of the box: it emits a <picture> that prefers the AVIF/WebP variants the
|
|
3
|
+
// glashjs asset optimizer already produced at build time, with no runtime image
|
|
4
|
+
// server, no signed URLs, and no config. Width/height are required-by-habit to
|
|
5
|
+
// prevent layout shift (CLS), and images lazy-load + async-decode by default.
|
|
6
|
+
//
|
|
7
|
+
// import { Image } from 'glashjs/image';
|
|
8
|
+
// <Image src="/hero.png" alt="Hero" width={1200} height={630} />
|
|
9
|
+
//
|
|
10
|
+
// After `glash build`, /hero.avif and /hero.webp exist next to /hero.png, so
|
|
11
|
+
// the browser downloads the smallest format it supports. Deterministic output
|
|
12
|
+
// (same on server + during hydration), so it never causes a hydration mismatch.
|
|
13
|
+
import { h } from 'preact';
|
|
14
|
+
|
|
15
|
+
const RASTER = /\.(png|jpe?g|webp|avif)$/i;
|
|
16
|
+
|
|
17
|
+
export function Image({ src, alt = '', width, height, sizes, loading = 'lazy', fetchpriority, class: className, style, ...rest }) {
|
|
18
|
+
if (!src || !RASTER.test(src)) {
|
|
19
|
+
// SVG/unknown: render a plain <img>, nothing to transcode.
|
|
20
|
+
return h('img', { src, alt, width, height, loading, decoding: 'async', class: className, style, ...rest });
|
|
21
|
+
}
|
|
22
|
+
const base = src.replace(RASTER, '');
|
|
23
|
+
return h(
|
|
24
|
+
'picture',
|
|
25
|
+
{ class: className, style },
|
|
26
|
+
h('source', { srcset: `${base}.avif`, type: 'image/avif', sizes }),
|
|
27
|
+
h('source', { srcset: `${base}.webp`, type: 'image/webp', sizes }),
|
|
28
|
+
h('img', {
|
|
29
|
+
src,
|
|
30
|
+
alt,
|
|
31
|
+
width,
|
|
32
|
+
height,
|
|
33
|
+
sizes,
|
|
34
|
+
loading,
|
|
35
|
+
fetchpriority,
|
|
36
|
+
decoding: 'async',
|
|
37
|
+
...rest,
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default Image;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// glashjs <Video> — zero-config video that prefers the AV1/WebM the glashjs
|
|
2
|
+
// optimizer produced at build time, with the original as fallback and an
|
|
3
|
+
// auto-derived poster frame. Deterministic output (SSR + hydration match).
|
|
4
|
+
//
|
|
5
|
+
// import { Video } from 'glashjs/video';
|
|
6
|
+
// <Video src="/clip.mp4" width={1280} height={720} />
|
|
7
|
+
//
|
|
8
|
+
// After `glash build`, /clip.glash.webm (AV1) and /clip.poster.jpg exist, so
|
|
9
|
+
// browsers stream the far-smaller AV1 and fall back to the mp4 otherwise.
|
|
10
|
+
import { h } from 'preact';
|
|
11
|
+
|
|
12
|
+
const VID = /\.(mp4|mov|webm|m4v)$/i;
|
|
13
|
+
|
|
14
|
+
export function Video({ src, poster, width, height, controls = true, autoplay, loop, muted, playsinline, preload = 'metadata', class: className, style, ...rest }) {
|
|
15
|
+
if (!src || !VID.test(src)) {
|
|
16
|
+
return h('video', { src, poster, width, height, controls, preload, class: className, style, ...rest });
|
|
17
|
+
}
|
|
18
|
+
const base = src.replace(VID, '');
|
|
19
|
+
return h(
|
|
20
|
+
'video',
|
|
21
|
+
{ width, height, controls, autoplay, loop, muted, playsinline, preload, poster: poster || `${base}.poster.jpg`, class: className, style, ...rest },
|
|
22
|
+
h('source', { src: `${base}.glash.webm`, type: 'video/webm' }),
|
|
23
|
+
h('source', { src, type: 'video/mp4' }),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default Video;
|
package/src/index.mjs
CHANGED
|
@@ -5,6 +5,8 @@ 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';
|
|
8
|
+
export { createGlashServer, json, redirect } from './server/server.mjs';
|
|
9
|
+
export { discoverRoutes, matchRoute, findMiddleware } from './server/router.mjs';
|
|
10
10
|
export { html, raw, escapeHtml, renderDocument } from './server/html.mjs';
|
|
11
|
+
export { Image } from './components/image.mjs';
|
|
12
|
+
export { Video } from './components/video.mjs';
|
package/src/server/jsx.mjs
CHANGED
|
@@ -101,12 +101,19 @@ if (el) hydrate(tree, el);`;
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/** Compile page + its layout chain into a server module ({ Page, layouts, getServerData, title }). */
|
|
104
|
-
export async function loadComponentRoute(pageFile, layouts, root, dev) {
|
|
105
|
-
const eb = await esbuild();
|
|
106
|
-
if (!eb) throw new Error(MISSING);
|
|
104
|
+
export async function loadComponentRoute(pageFile, layouts, root, dev, force = false) {
|
|
107
105
|
const id = compId(pageFile, layouts);
|
|
108
106
|
if (!dev && serverCache.has(id)) return serverCache.get(id);
|
|
109
107
|
const out = path.join(root, '.glash', 'server', id + '.mjs');
|
|
108
|
+
// Production: if `glash build` already precompiled this module, import it
|
|
109
|
+
// directly — no esbuild needed on the serving host.
|
|
110
|
+
if (!dev && !force && existsSync(out)) {
|
|
111
|
+
const mod = await import(pathToFileURL(out).href);
|
|
112
|
+
serverCache.set(id, mod);
|
|
113
|
+
return mod;
|
|
114
|
+
}
|
|
115
|
+
const eb = await esbuild();
|
|
116
|
+
if (!eb) throw new Error(MISSING);
|
|
110
117
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
111
118
|
await eb.build({
|
|
112
119
|
stdin: { contents: serverEntry(pageFile, layouts), resolveDir: path.dirname(pageFile), loader: 'js', sourcefile: 'glash-server-entry.js' },
|
package/src/server/router.mjs
CHANGED
|
@@ -7,9 +7,32 @@
|
|
|
7
7
|
// routes/docs/[...path].mjs -> /docs/*path (catch-all)
|
|
8
8
|
// routes/api/hello.mjs -> /api/hello (API route — exports GET/POST/…)
|
|
9
9
|
// Anything under routes/api/ is an API route; everything else is a page (SSR).
|
|
10
|
-
import { promises as fs } from 'node:fs';
|
|
10
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* File-based middleware: `_middleware.{mjs,js}` in any routes dir runs before
|
|
15
|
+
* the route, root→leaf. Each default-exports `(ctx) => Response | void` — return
|
|
16
|
+
* a value to short-circuit (redirect/auth/json), or nothing to continue. The
|
|
17
|
+
* root middleware runs for every request; `dash/_middleware` only for /dash/*.
|
|
18
|
+
*/
|
|
19
|
+
export function findMiddleware(routesDir, routeFile) {
|
|
20
|
+
const root = path.resolve(routesDir);
|
|
21
|
+
const dir = path.dirname(path.resolve(routeFile));
|
|
22
|
+
const rel = path.relative(root, dir);
|
|
23
|
+
const dirs = [root];
|
|
24
|
+
let acc = root;
|
|
25
|
+
for (const part of rel ? rel.split(path.sep) : []) { acc = path.join(acc, part); dirs.push(acc); }
|
|
26
|
+
const files = [];
|
|
27
|
+
for (const d of dirs) {
|
|
28
|
+
for (const name of ['_middleware.mjs', '_middleware.js']) {
|
|
29
|
+
const f = path.join(d, name);
|
|
30
|
+
if (existsSync(f)) { files.push(f); break; }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
export async function discoverRoutes(routesDir) {
|
|
14
37
|
const root = path.resolve(routesDir);
|
|
15
38
|
const files = [];
|
package/src/server/server.mjs
CHANGED
|
@@ -10,7 +10,7 @@ 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
|
-
import { discoverRoutes, matchRoute } from './router.mjs';
|
|
13
|
+
import { discoverRoutes, matchRoute, findMiddleware } from './router.mjs';
|
|
14
14
|
import { renderDocument, documentParts } from './html.mjs';
|
|
15
15
|
import { isComponentRoute, loadComponentRoute, clientBundle, renderComponent, routeId, findLayouts, clearJsxCaches } from './jsx.mjs';
|
|
16
16
|
import { securityHeaders } from '../security/headers.mjs';
|
|
@@ -61,7 +61,10 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
61
61
|
req.on('close', () => hmrClients.delete(res));
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
|
-
//
|
|
64
|
+
// Static first: in production this serves prebuilt /_glash/<id>.js bundles
|
|
65
|
+
// (written by `glash build`) — no runtime esbuild needed.
|
|
66
|
+
if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
|
|
67
|
+
// Dynamic hydration bundles (dev, or when not prebuilt): /_glash/<routeId>.js
|
|
65
68
|
if (pathname.startsWith('/_glash/')) {
|
|
66
69
|
const id = pathname.slice('/_glash/'.length).replace(/\.js$/, '');
|
|
67
70
|
const comp = routes.find((r) => isComponentRoute(r.file) && routeId(r.file) === id);
|
|
@@ -69,10 +72,17 @@ export async function createGlashServer({ root = process.cwd(), dev = false } =
|
|
|
69
72
|
const js = await clientBundle(comp.file, findLayouts(routesDir, comp.file), dev);
|
|
70
73
|
return send(res, 200, 'text/javascript; charset=utf-8', js, { ...secHeaders, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' });
|
|
71
74
|
}
|
|
72
|
-
if (await serveStatic(res, outDir, pathname, req, secHeaders)) return;
|
|
73
75
|
const match = matchRoute(routes, pathname);
|
|
74
76
|
if (!match) return send(res, 404, 'text/plain; charset=utf-8', 'Not found', secHeaders);
|
|
75
|
-
const ctx = makeCtx(req, url, match.params);
|
|
77
|
+
const ctx = makeCtx(req, res, url, match.params);
|
|
78
|
+
// Run the middleware chain (root -> leaf). Any return value short-circuits.
|
|
79
|
+
for (const mwFile of findMiddleware(routesDir, match.route.file)) {
|
|
80
|
+
const mwMod = await importRoute(mwFile);
|
|
81
|
+
const mw = mwMod.default || mwMod.middleware;
|
|
82
|
+
if (typeof mw !== 'function') continue;
|
|
83
|
+
const result = await mw(ctx);
|
|
84
|
+
if (result) return sendMiddlewareResult(res, result, secHeaders);
|
|
85
|
+
}
|
|
76
86
|
if (match.route.isApi) {
|
|
77
87
|
const mod = await importRoute(match.route.file);
|
|
78
88
|
return await handleApi(res, mod, req, ctx, secHeaders);
|
|
@@ -178,9 +188,10 @@ async function serveStatic(res, outDir, pathname, req, secHeaders) {
|
|
|
178
188
|
return false;
|
|
179
189
|
}
|
|
180
190
|
|
|
181
|
-
function makeCtx(req, url, params) {
|
|
191
|
+
function makeCtx(req, res, url, params) {
|
|
182
192
|
return {
|
|
183
193
|
req,
|
|
194
|
+
res,
|
|
184
195
|
method: req.method,
|
|
185
196
|
url,
|
|
186
197
|
path: url.pathname,
|
|
@@ -213,3 +224,22 @@ function safeDecode(p) {
|
|
|
213
224
|
export function json(body, { status = 200, headers } = {}) {
|
|
214
225
|
return { __response: true, status, contentType: 'application/json', body, headers };
|
|
215
226
|
}
|
|
227
|
+
|
|
228
|
+
/** Middleware/handler helper: redirect to another path. */
|
|
229
|
+
export function redirect(location, { status = 302 } = {}) {
|
|
230
|
+
return { __redirect: location, status };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sendMiddlewareResult(res, result, secHeaders) {
|
|
234
|
+
if (result.__redirect) {
|
|
235
|
+
res.writeHead(result.status || 302, { ...secHeaders, location: result.__redirect });
|
|
236
|
+
return res.end();
|
|
237
|
+
}
|
|
238
|
+
if (result.__response) {
|
|
239
|
+
return send(res, result.status || 200, result.contentType || 'application/json',
|
|
240
|
+
typeof result.body === 'string' ? result.body : JSON.stringify(result.body), { ...secHeaders, ...(result.headers || {}) });
|
|
241
|
+
}
|
|
242
|
+
// A bare object/string from middleware is treated as a JSON/text body.
|
|
243
|
+
if (typeof result === 'string') return send(res, 200, 'text/plain; charset=utf-8', result, secHeaders);
|
|
244
|
+
return send(res, 200, 'application/json', JSON.stringify(result), secHeaders);
|
|
245
|
+
}
|